<!DOCTYPE html><html lang="zh-CN" data-theme="light"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AI Trip Assistant 复盘 | Dingzh's Blog</title><meta name="keywords" content="Java,AI"><meta name="author" content="Dingzh"><meta name="copyright" content="Dingzh"><meta name="format-detection" content="telephone=no"><meta name="theme-color" content="#ffffff"><meta name="description" content="USYD 5620 小组项目AI Trip Assistant 复盘"><meta property="og:type" content="article"><meta property="og:title" content="AI Trip Assistant 复盘"><meta property="og:url" content="https://dingzh.cc/posts/b95b9d3f.html"><meta property="og:site_name" content="Dingzh&#39;s Blog"><meta property="og:description" content="USYD 5620 小组项目AI Trip Assistant 复盘"><meta property="og:locale" content="zh_CN"><meta property="og:image" content="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover186.jpg"><meta property="article:published_time" content="2025-10-27T14:28:21.000Z"><meta property="article:modified_time" content="2025-11-11T15:48:02.014Z"><meta property="article:author" content="Dingzh"><meta property="article:tag" content="Java"><meta property="article:tag" content="AI"><meta name="twitter:card" content="summary"><meta name="twitter:image" content="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover186.jpg"><link rel="shortcut icon" href="https://alicdn.dingzh.cc/ding-BlogDemo/img/Favicon.png"><link rel="canonical" href="https://dingzh.cc/posts/b95b9d3f"><link rel="preconnect" href="//cdn.jsdelivr.net"><link rel="preconnect" href="//busuanzi.ibruce.info"><link rel="manifest" href="/manifest.json"><meta name="msapplication-TileColor" content="#f7cb98"><link rel="apple-touch-icon" sizes="180x180" href="https://alicdn.dingzh.cc/ding-BlogDemo/img/siteicon/apple-touch-icon.png"><link rel="icon" type="image/png" sizes="32x32" href="https://alicdn.dingzh.cc/ding-BlogDemo/img/siteicon/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="https://alicdn.dingzh.cc/ding-BlogDemo/img/siteicon/favicon-16x16.png"><link rel="mask-icon" href="https://alicdn.dingzh.cc/ding-BlogDemo/img/siteicon/safari-pinned-tab.svg" color="#5bbad5"><link rel="stylesheet" href="/css/index.css"><link rel="stylesheet" href="https://npm.elemecdn.com/@fortawesome/fontawesome-free/css/all.min.css" media="print" onload='this.media="all"'><link rel="stylesheet" href="https://npm.elemecdn.com/node-snackbar/dist/snackbar.min.css" media="print" onload='this.media="all"'><script>const GLOBAL_CONFIG={root:"/",algolia:void 0,localSearch:{path:"search.xml",languages:{hits_empty:"找不到您查询的内容：${query}"}},translate:void 0,noticeOutdate:void 0,highlight:{plugin:"highlighjs",highlightCopy:!0,highlightLang:!0,highlightHeightLimit:250},copy:{success:"复制成功",error:"复制错误",noSupport:"浏览器不支持"},relativeDate:{homepage:!1,post:!1},runtime:"天",date_suffix:{just:"刚刚",min:"分钟前",hour:"小时前",day:"天前",month:"个月前"},copyright:void 0,lightbox:"fancybox",Snackbar:{chs_to_cht:"你已切换为繁体",cht_to_chs:"你已切换为简体",day_to_night:"你已切换为深色模式",night_to_day:"你已切换为浅色模式",bgLight:"#ff6262",bgDark:"#7a3939",position:"top-center"},source:{jQuery:"https://npm.elemecdn.com/jquery@latest/dist/jquery.min.js",justifiedGallery:{js:"https://npm.elemecdn.com/justifiedGallery/dist/js/jquery.justifiedGallery.min.js",css:"https://npm.elemecdn.com/justifiedGallery/dist/css/justifiedGallery.min.css"},fancybox:{js:"https://alicdn.dingzh.cc/ding-BlogDemo/js/other/jquery.fancybox.min.js",css:"https://npm.elemecdn.com/@fancyapps/fancybox@latest/dist/jquery.fancybox.min.css"}},isPhotoFigcaption:!1,islazyload:!0,isanchor:!1}</script><script id="config-diff">var GLOBAL_CONFIG_SITE={title:"AI Trip Assistant 复盘",isPost:!0,isHome:!1,isHighlightShrink:!1,isToc:!0,postUpdate:"2025-11-11 23:48:02"}</script><noscript><style>#nav{opacity:1}.justified-gallery img{opacity:1}#post-meta time,#recent-posts time{display:inline!important}</style></noscript><script>(e=>{e.saveToLocal={set:function(e,t,o){if(0===o)return;const n=864e5*o,a={value:t,expiry:(new Date).getTime()+n};localStorage.setItem(e,JSON.stringify(a))},get:function(e){const t=localStorage.getItem(e);if(!t)return;const o=JSON.parse(t);if(!((new Date).getTime()>o.expiry))return o.value;localStorage.removeItem(e)}},e.getScript=e=>new Promise((t,o)=>{const n=document.createElement("script");n.src=e,n.async=!0,n.onerror=o,n.onload=n.onreadystatechange=function(){const e=this.readyState;e&&"loaded"!==e&&"complete"!==e||(n.onload=n.onreadystatechange=null,t())},document.head.appendChild(n)}),e.activateDarkMode=function(){document.documentElement.setAttribute("data-theme","dark"),null!==document.querySelector('meta[name="theme-color"]')&&document.querySelector('meta[name="theme-color"]').setAttribute("content","#0d0d0d")},e.activateLightMode=function(){document.documentElement.setAttribute("data-theme","light"),null!==document.querySelector('meta[name="theme-color"]')&&document.querySelector('meta[name="theme-color"]').setAttribute("content","#ffffff")};const t=saveToLocal.get("theme"),o=(new Date).getHours();void 0===t?o<=6||o>=18?activateDarkMode():activateLightMode():"light"===t?activateLightMode():activateDarkMode();const n=saveToLocal.get("aside-status");void 0!==n&&("hide"===n?document.documentElement.classList.add("hide-aside"):document.documentElement.classList.remove("hide-aside"));const a=saveToLocal.get("global-font-size");void 0!==a&&document.documentElement.style.setProperty("--global-font-size",a+"px");const c=()=>{GLOBAL_CONFIG_SITE.isHome&&/iPad|iPhone|iPod|Macintosh/.test(navigator.userAgent)&&document.documentElement.classList.add("apple")};c(),document.addEventListener("pjax:complete",c)})(window)</script><script defer src="https://alicdn.dingzh.cc/ding-BlogDemo/js/custom/randombg.min.js"></script><svg aria-hidden="true" style="position:absolute;overflow:hidden;width:0;height:0"><symbol id="icon-sun" viewBox="0 0 1024 1024"><path d="M960 512l-128 128v192h-192l-128 128-128-128H192v-192l-128-128 128-128V192h192l128-128 128 128h192v192z" fill="#FFD878" p-id="8420"></path><path d="M736 512a224 224 0 1 0-448 0 224 224 0 1 0 448 0z" fill="#FFE4A9" p-id="8421"></path><path d="M512 109.248 626.752 224H800v173.248L914.752 512 800 626.752V800h-173.248L512 914.752 397.248 800H224v-173.248L109.248 512 224 397.248V224h173.248L512 109.248M512 64l-128 128H192v192l-128 128 128 128v192h192l128 128 128-128h192v-192l128-128-128-128V192h-192l-128-128z" fill="#4D5152" p-id="8422"></path><path d="M512 320c105.888 0 192 86.112 192 192s-86.112 192-192 192-192-86.112-192-192 86.112-192 192-192m0-32a224 224 0 1 0 0 448 224 224 0 0 0 0-448z" fill="#4D5152" p-id="8423"></path></symbol><symbol id="icon-moon" viewBox="0 0 1024 1024"><path d="M611.370667 167.082667a445.013333 445.013333 0 0 1-38.4 161.834666 477.824 477.824 0 0 1-244.736 244.394667 445.141333 445.141333 0 0 1-161.109334 38.058667 85.077333 85.077333 0 0 0-65.066666 135.722666A462.08 462.08 0 1 0 747.093333 102.058667a85.077333 85.077333 0 0 0-135.722666 65.024z" fill="#FFB531" p-id="11345"></path><path d="M329.728 274.133333l35.157333-35.157333a21.333333 21.333333 0 1 0-30.165333-30.165333l-35.157333 35.157333-35.114667-35.157333a21.333333 21.333333 0 0 0-30.165333 30.165333l35.114666 35.157333-35.114666 35.157334a21.333333 21.333333 0 1 0 30.165333 30.165333l35.114667-35.157333 35.157333 35.157333a21.333333 21.333333 0 1 0 30.165333-30.165333z" fill="#030835" p-id="11346"></path></symbol></svg><link rel="stylesheet" href="https://alicdn.dingzh.cc/ding-BlogDemo/css/card.min.css" media="defer" onload='this.media="all"'><link rel="stylesheet" href="https://alicdn.dingzh.cc/ding-BlogDemo/clock/clock.min.css"><link rel="stylesheet" href="https://npm.elemecdn.com/hexo-butterfly-swiper/lib/swiper.min.css" media="print" onload='this.media="all"'><link rel="stylesheet" href="https://alicdn.dingzh.cc/ding-BlogDemo/css/swiperstyle.min.css" media="print" onload='this.media="all"'><link rel="stylesheet" href="https://npm.elemecdn.com/hexo-butterfly-wowjs/lib/animate.min.css" media="print" onload='this.media="screen"'><link rel="stylesheet" href="https://npm.elemecdn.com/hexo-butterfly-tag-plugins-plus@latest/lib/assets/font-awesome-animation.min.css" media="defer" onload='this.media="all"'><link rel="stylesheet" href="https://alicdn.dingzh.cc/ding-BlogDemo/css/tag_plugins.min.css" media="defer" onload='this.media="all"'><meta name="generator" content="Hexo 6.0.0"></head><body><a href="javascript:void(0);" onclick="preloader.endLoading()" title="点击跳过动画"><div id="loading-box"><div class="pokeball-back"></div><div class="pokeball-loading"><div class="pokeball" id="pokeball-normal"></div><div class="pokeball" id="pokeball-great"></div><div class="pokeball" id="pokeball-ultra"></div><div class="pokeball" id="pokeball-master"></div><div class="pokeball" id="pokeball-safari"></div></div></div></a><div id="web_bg"></div><div id="sidebar"><div id="menu-mask"></div><div id="sidebar-menus"><div class="avatar-img is-center"><img src="https://alicdn.dingzh.cc/ding-BlogDemo/img/head1.jpg" onerror='onerror=null,src="https://alicdn.dingzh.cc/ding-BlogDemo/img/imgLink_404.gif"' alt="avatar"></div><div class="site-data"><div class="data-item is-center"><div class="data-item-link"><a href="/archives/"><div class="headline">文章</div><div class="length-num">38</div></a></div></div><div class="data-item is-center"><div class="data-item-link"><a href="/tags/"><div class="headline">标签</div><div class="length-num">49</div></a></div></div><div class="data-item is-center"><div class="data-item-link"><a href="/categories/"><div class="headline">分类</div><div class="length-num">9</div></a></div></div></div><hr><div class="menus_items"><div class="menus_item"><a class="site-page faa-parent animated-hover" href="/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-shouye"></use></svg> <span>首页</span></a></div><div class="menus_item"><a class="site-page faa-parent animated-hover" href="javascript:void(0);"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-zixun"></use></svg> <span>文章</span><i class="fas fa-chevron-down expand"></i></a><ul class="menus_item_child"><li><a class="site-page child faa-parent animated-hover" href="/archives/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-danganku"></use></svg> <span>归档</span></a></li><li><a class="site-page child faa-parent animated-hover" href="/tags/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-biaoqian"></use></svg> <span>标签</span></a></li><li><a class="site-page child faa-parent animated-hover" href="/categories/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-wenjianjia"></use></svg> <span>分类</span></a></li></ul></div><div class="menus_item"><a class="site-page faa-parent animated-hover" href="javascript:void(0);"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-guochandonghuatuijian"></use></svg> <span>动漫</span><i class="fas fa-chevron-down expand"></i></a><ul class="menus_item_child"><li><a class="site-page child faa-parent animated-hover" href="/anime/tv/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-anime"></use></svg> <span>季度</span></a></li><li><a class="site-page child faa-parent animated-hover" href="/anime/collect/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-a-vpszhujifuwuqiwangluo"></use></svg> <span>藏馆</span></a></li><li><a class="site-page child faa-parent animated-hover" target="_blank" rel="noopener" href="https://github.com/rplalala/MySubTittle"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-dianying"></use></svg> <span>字幕</span></a></li></ul></div><div class="menus_item"><a class="site-page faa-parent animated-hover" href="javascript:void(0);"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-a-switch2"></use></svg> <span>其他</span><i class="fas fa-chevron-down expand"></i></a><ul class="menus_item_child"><li><a class="site-page child faa-parent animated-hover" href="/gallery/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-xiangce"></use></svg> <span>图库</span></a></li><li><a class="site-page child faa-parent animated-hover" href="/artitalk/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-pinglun1"></use></svg> <span>随言</span></a></li></ul></div><div class="menus_item"><a class="site-page faa-parent animated-hover" href="/messageboard/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-rili"></use></svg> <span>留言板</span></a></div><div class="menus_item"><a class="site-page faa-parent animated-hover" href="javascript:void(0);"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-youqinglianjie"></use></svg> <span>链接</span><i class="fas fa-chevron-down expand"></i></a><ul class="menus_item_child"><li><a class="site-page child faa-parent animated-hover" href="/link/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-friendship"></use></svg> <span>友链</span></a></li><li><a class="site-page child faa-parent animated-hover" target="_blank" rel="noopener" href="https://vercel.dingzh.cc"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-a-internethulianwangwangluo"></use></svg> <span>镜像</span></a></li><li><a class="site-page child faa-parent animated-hover" href="https://dingzh.cc"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-a-vpszhujifuwuqiwangluo"></use></svg> <span>主站</span></a></li></ul></div><div class="menus_item"><a class="site-page faa-parent animated-hover" href="/about/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-wode2"></use></svg> <span>关于</span></a></div></div></div></div><div class="post" id="body-wrap"><header class="post-bg" id="page-header" style="background-image:url(https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover186.jpg)"><nav id="nav"><span id="blog_name"><a id="site-name" onclick="btf.scrollToDest(0,500)" data-title="USYD 5620 小组项目AI Trip Assistant 复盘">AI Trip Assistant 复盘</a></span><div id="menus"><div id="search-button"><a class="site-page social-icon search faa-parent animated-hover"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-sousuo4"></use></svg> <span>搜索</span></a></div><div class="menus_items"><div class="menus_item"><a class="site-page faa-parent animated-hover" href="/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-shouye"></use></svg> <span>首页</span></a></div><div class="menus_item"><a class="site-page faa-parent animated-hover" href="javascript:void(0);"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-zixun"></use></svg> <span>文章</span><i class="fas fa-chevron-down expand"></i></a><ul class="menus_item_child"><li><a class="site-page child faa-parent animated-hover" href="/archives/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-danganku"></use></svg> <span>归档</span></a></li><li><a class="site-page child faa-parent animated-hover" href="/tags/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-biaoqian"></use></svg> <span>标签</span></a></li><li><a class="site-page child faa-parent animated-hover" href="/categories/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-wenjianjia"></use></svg> <span>分类</span></a></li></ul></div><div class="menus_item"><a class="site-page faa-parent animated-hover" href="javascript:void(0);"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-guochandonghuatuijian"></use></svg> <span>动漫</span><i class="fas fa-chevron-down expand"></i></a><ul class="menus_item_child"><li><a class="site-page child faa-parent animated-hover" href="/anime/tv/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-anime"></use></svg> <span>季度</span></a></li><li><a class="site-page child faa-parent animated-hover" href="/anime/collect/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-a-vpszhujifuwuqiwangluo"></use></svg> <span>藏馆</span></a></li><li><a class="site-page child faa-parent animated-hover" target="_blank" rel="noopener" href="https://github.com/rplalala/MySubTittle"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-dianying"></use></svg> <span>字幕</span></a></li></ul></div><div class="menus_item"><a class="site-page faa-parent animated-hover" href="javascript:void(0);"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-a-switch2"></use></svg> <span>其他</span><i class="fas fa-chevron-down expand"></i></a><ul class="menus_item_child"><li><a class="site-page child faa-parent animated-hover" href="/gallery/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-xiangce"></use></svg> <span>图库</span></a></li><li><a class="site-page child faa-parent animated-hover" href="/artitalk/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-pinglun1"></use></svg> <span>随言</span></a></li></ul></div><div class="menus_item"><a class="site-page faa-parent animated-hover" href="/messageboard/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-rili"></use></svg> <span>留言板</span></a></div><div class="menus_item"><a class="site-page faa-parent animated-hover" href="javascript:void(0);"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-youqinglianjie"></use></svg> <span>链接</span><i class="fas fa-chevron-down expand"></i></a><ul class="menus_item_child"><li><a class="site-page child faa-parent animated-hover" href="/link/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-friendship"></use></svg> <span>友链</span></a></li><li><a class="site-page child faa-parent animated-hover" target="_blank" rel="noopener" href="https://vercel.dingzh.cc"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-a-internethulianwangwangluo"></use></svg> <span>镜像</span></a></li><li><a class="site-page child faa-parent animated-hover" href="https://dingzh.cc"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-a-vpszhujifuwuqiwangluo"></use></svg> <span>主站</span></a></li></ul></div><div class="menus_item"><a class="site-page faa-parent animated-hover" href="/about/"><svg class="icon faa-tada" aria-hidden="true"><use xlink:href="#icon-wode2"></use></svg> <span>关于</span></a></div></div><div id="toggle-menu"><a class="site-page"><i class="fas fa-bars fa-fw"></i></a></div></div></nav><div id="post-info"><h1 class="post-title">AI Trip Assistant 复盘<a class="post-edit-link" href="https://github.com/rplalala/BlogSource/edit/main/source/_posts/AI-Trip-Assistant-复盘.md" title="编辑" target="_blank"><i class="fas fa-pencil-alt"></i></a></h1><div id="post-meta"><div class="meta-firstline"><span class="post-meta-date"><i class="far fa-calendar-alt fa-fw post-meta-icon"></i><span class="post-meta-label">发表于</span><time class="post-meta-date-created" datetime="2025-10-27T14:28:21.000Z" title="发表于 2025-10-27 22:28:21">2025-10-27</time><span class="post-meta-separator">|</span><i class="fas fa-history fa-fw post-meta-icon"></i><span class="post-meta-label">更新于</span><time class="post-meta-date-updated" datetime="2025-11-11T15:48:02.014Z" title="更新于 2025-11-11 23:48:02">2025-11-11</time></span><span class="post-meta-categories"><span class="post-meta-separator">|</span><i class="fas fa-inbox fa-fw post-meta-icon"></i><a class="post-meta-categories" href="/categories/AI/">AI</a></span></div><div class="meta-secondline"><span class="post-meta-separator">|</span><span class="post-meta-wordcount"><i class="far fa-file-word fa-fw post-meta-icon"></i><span class="post-meta-label">字数总计:</span><span class="word-count">6.4k</span><span class="post-meta-separator">|</span><i class="far fa-clock fa-fw post-meta-icon"></i><span class="post-meta-label">阅读时长:</span><span>30分钟</span></span><span class="post-meta-separator">|</span><span class="post-meta-pv-cv" data-flag-title="AI Trip Assistant 复盘"><i class="far fa-eye fa-fw post-meta-icon"></i><span class="post-meta-label">阅读量:</span><span id="busuanzi_value_page_pv"></span></span></div></div></div></header><main class="layout" id="content-inner"><div id="post"><article class="post-content" id="article-container"><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><div class="note info no-icon simple"><p><a target="_blank" rel="noopener" href="https://aitrip.dingzh.cc">Live Demo</a></p><p><a target="_blank" rel="noopener" href="https://github.com/rplalala/AI_Trip_Assistant">GitHub</a></p></div><p>简单来说，这个项目实际上并没有实现一个 完整的 AI AGENT 架构，而是一个<strong>AI 参与的工作流</strong>。AI 在这个工作流中只负责生成内容（返回文本/JSON），也就是调用了 LLM 来“思考”，而函数的调用、执行和整体流程都是后端解析 AI 生成的文本后进行处理的。</p><p>在以后，可以尝试把这个项目后端的一些 Services 抽离出一个 MCP Server，让 AI 能够自主选择调用这些函数/工具，比如说：收集用户偏好、生成旅行计划、更新天气信息等。这样就能更接近一个完整的 AI AGENT 架构，让 AI 不仅能“思考”，还能“动手”，并且自主决定下一步做什么。</p><hr><p><strong>清理任务：</strong></p><ol><li>定期清理AWS S3，删除数据库内不存在但AWS S3上存在的冗余文件</li><li>定期清理email_token table， 删除过期的token</li><li>定期检查trip表，删除user不存在的trip及其子表 (trip_weather, trip_insights …)</li></ol><p><strong>登录鉴权：</strong></p><ol><li>分发jwt时可以把userId存在jwt token 中的 subject，这样就不需要前端传入userId，在controller中用@AuthenticationPrincipal注解即可从subject中取出userId（前提是你subject中只存了userId）。jwt token 中的claims也可以存一些非保密字段，比如说username, email.</li><li>AuthConfig中的异常是在过滤器链中发生的，不会被全局异常处理器捕捉。所以要在AuthConfig中用.authenticationEntryPoint和.accessDeniedHandler处理。</li><li>在jwtFilter中，异常不用留给全局异常处理器，直接catch然后SecurityContextHolder.clearContext();清空上下文就行。（因为不做任何处理，没有用.setAuthentication把jwt token放到header中，在controller层请求就会因为unauthenticated而被驳回）</li></ol><p><strong>前后端跨域：</strong></p><ol><li>第一种方法：后端配置CorsConfig，开启 CORS</li><li>第二种方法：前端反向代理到后端。（例如：开发期可用 vite.config.ts，生成部署阶段用 Nginx 反代）</li></ol><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/*</span></span><br><span class="line"><span class="comment">* 开发环境下 Vite 转发请求使前后端同源（生成环境下用 Nginx）</span></span><br><span class="line"><span class="comment">* 在 Vite 的 server.proxy 里，</span></span><br><span class="line"><span class="comment">* 只有以 /api 开头的请求才会被代理到 target；其它路径仍由 Vite 本地开发服务器处理。</span></span><br><span class="line"><span class="comment">*</span></span><br><span class="line"><span class="comment">* GET http://localhost:5173/api/test → 代理为 GET http://localhost:8082/api/test</span></span><br><span class="line"><span class="comment">* GET http://localhost:5173/auth/login → 不代理，仍由 Vite 处理</span></span><br><span class="line"><span class="comment">*</span></span><br><span class="line"><span class="comment">* */</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> &#123; defineConfig &#125; <span class="keyword">from</span> <span class="string">&#x27;vite&#x27;</span></span><br><span class="line"><span class="keyword">import</span> react <span class="keyword">from</span> <span class="string">&#x27;@vitejs/plugin-react-swc&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">export</span> <span class="keyword">default</span> defineConfig(&#123;</span><br><span class="line">    <span class="attr">plugins</span>: [react()],</span><br><span class="line">    <span class="attr">server</span>: &#123;</span><br><span class="line">        <span class="attr">port</span>: <span class="number">5173</span>,</span><br><span class="line">        <span class="attr">proxy</span>: &#123;</span><br><span class="line">            <span class="string">&#x27;/api&#x27;</span>: &#123; <span class="attr">target</span>: <span class="string">&#x27;http://localhost:8082&#x27;</span>, <span class="attr">changeOrigin</span>: <span class="literal">true</span> &#125;,</span><br><span class="line">        &#125;,</span><br><span class="line">    &#125;,</span><br><span class="line">&#125;)</span><br><span class="line"></span><br></pre></td></tr></table></figure><p><strong>邮箱验证:</strong></p><ol><li>未注册用户第一次注册时，会发送一封验证邮件到注册邮箱，用户点击则user表email_verifed字段为true，同时分发jwt自动登录并跳转到my trip页面</li><li>用户注册后但未验证邮箱时，无法登录。在登陆时会提示用户尚未验证，并询问用户是否要再次发送验证邮件，用户点击是则发送，后续逻辑如1</li><li>用户忘记密码时，点击forgot password并输入注册时邮箱，会发送一封reset password邮件，用户点击并填写表单（新密码），提交后即可更新密码。注：如果该用户注册后尚未验证邮箱，通过forgot password修改密码后，会看作已验证，将email_verifed字段为true</li><li>用户可以修改邮箱。先发送一封带有表单的链接给当前邮箱，用户输入新邮箱账号并确认后，会再发送一封验证link给新邮箱，用户点击后则修改成功，并自动跳转回profile页面</li><li>所有邮箱link附带token，token有过期和使用标记，当发送新的验证link时，之前的验证link不在允许使用，即便仍未过期</li></ol><p><strong>Agent:</strong></p><ol><li>定义prompt，并要求AI返回严格的JSON格式。</li><li>用 objectMapper.readValue(json, dto)方法反序列化json，将规范的json转换成对应的dto，这样就可以被java操作了（注意：OPENAI返回的JSON一般不规范，这时需要先用objectMapper.readTree(openaiJson)解析openAI返回的json，找到其中真正规范的json，比如：realJson = objectMapper.readTree(openAiJson).path(“choices”).get(0).path(“message”).path(“content”).asText()，再用readValue去反序列化 realJson ）</li><li>反序列化成DTO对象后就简单了，对其进行相应操作就行，比如存库</li><li>re-plan. 前端：用户点击replan，并输入 new preference。后端：定义一个新的prompt并插入新preference，新的prompt要说明把new preference作为最严格的约束，然后拼接new prompt和之前定义的基本prompt，同时更新天气信息（删除数据库中旧的天气信息）。最后生成的replan的计划应该是基于：新preference（最上层约束） + 旧plan基本信息（目的点，时间范围等） + 更新的天气。</li><li>普通的后端服务就像“双手”，它只能处理各种数据去实现业务，但不能思考。普通的AI chat就像“大脑”，它可以思考问题并回答，但并不能处理。而AI AGENT就是“大脑+双手”，先把需求告诉AI，AI （”大脑“）思考并告诉我们它的回答，后端业务（双手）再去解析这个回答（String,JSON）,把回答转换成具体的对象，并进行各种处理去实现业务。所以AI AGENT实际上并不是AI自动帮你做事，它还是像AI CHAT一样处理用户输入的消息（只不过后端可以额外加prompt）并返回回答（只不过回答没有直接给用户，而是交给后端处理），而正在实现处理的仍然是后端程序员通过解析AI的回答得到的信息写的各种业务。举个例子，AI AGENT实现根据药品信息自动定时提醒功能，这个功能并不是AI自动帮你完成了定时功能，实际上是AI根据药品信息和prompt（e.g. 你是一个药品提醒助手，请根据提供的药品信息返回正确的吃药时间）返回一个String（JSON）包含了每个药品的吃药时间给后端，然后后端解析这个String得到每个药品的吃药时间（比如说放在List，甚至说存在数据库里），然后再根据得到的吃药时间在后端写定时业务，和前端联动实现提醒功能。在这个例子中，虽然在用户的视角像是AI Agent帮他们自动定时了时间，但实际上AI只是思考并提供了吃药时间（这点和AI CHAT无异），而真正实现定时业务的仍然是后端程序员写的逻辑。</li></ol><div class="tip info"><p>对于第5点的逻辑，我那时写的时候还是太浅显了。这个项目严格来说不能算是完整的AI AGENT项目，真正要算，其实是有AI参与的workflow，强流程、弱自由度，AI 只在既定节点产出内容，执行仍由后端业务完成。用户输入 → 定义 Prompt → LLM 解析 → 返回结果 → 由后端处理与落地。</p><p>真正的AI AGENT应当拥有高自由度，能够自主选择并调用开发者定义好的函数/工具，根据工具返回决定是否继续、如何继续或何时回调/终止（可以了解一下 LangGraph 框架，能够帮助开发者编排这种流程）。</p><p>但其实 Ai Agent 本身应该也算是一种特殊的工作流？只不过在这个工作流中分支并没有写死，相对更自由些。和普通workflow和AI参与的workflow对比，AI AGENT 将选择下一步/执行哪些函数的决策交给AI来做（不仅仅是思考），但真正到执行函数这一步，还是要有后端业务参与。</p></div><p>普通工作流：选择、计划、执行都由后端代码决定。AI 参与的工作流：执行仍在后端；AI在既定节点“思考/生成”，分支总体仍按预设推进。AI Agent：把选择与计划交给模型，但执行与落地仍归后端；分支不是完全写死，而是在后端定义的受控分支/工具白名单中，由AI基于工具返回自主决定下一步（继续/停止/回调），并受步数/超时/预算等护栏约束。</p><div class="table-container"><table><thead><tr><th>形态</th><th>“选择/计划”</th><th>“执行/副作用”</th><th>分支定义方式</th><th>工具调用</th></tr></thead><tbody><tr><td>普通工作流</td><td>后端写死</td><td>后端</td><td>if/else/流程图写死</td><td>无/固定SDK调用</td></tr><tr><td>AI 参与的工作流</td><td><strong>AI 生成内容</strong>，但通常按既定节点走</td><td>后端</td><td>主干写死，某些节点让 AI 产出结构化结果</td><td>多为固定调用或人工审核后调用</td></tr><tr><td><strong>AI Agent</strong></td><td><strong>LLM 在白名单与状态机内做策略选择</strong></td><td>后端</td><td><strong>状态机/图</strong>：边受控、AI选边</td><td><strong>函数/工具调用</strong>（name+args→DTO→执行→结果回馈）</td></tr></tbody></table></div><p><strong>图片：</strong><br>插入cover：Agent生成img_subscription, 在根据img_subscription在unsplash上搜索返回第一张图片的Link作为cover<br>头像：用户被允许通过文件或链接上传头像，上传的头像存储在 aws s3</p><p><strong>中国大陆地区适配：</strong></p><ol><li>阿里云cdn 加速访问 ec2服务器（新加坡），优先访问边缘节点上的缓存（但效果有限，边缘节点无缓存时仍然需要访问新加坡源站。后续考虑部署国内云服务器，做双端源站）</li><li>后端服务器位于 ec2（新加坡）, 代替用户向 LLM (OpenAI) 发出请求，避免用户在中国大陆境内无法直接请求 LLM。</li><li>对于中国大陆无法直接访问的外部资源，例如 GeoDB Cites, Unsplash Image，在 ec2 (新加坡) 端用 Nginx 反向代理，把请求改为从ec2端发出，避免用户在中国大陆直接请求。</li><li>适配高德地图，用户访问时用一个很小的Google资源作为探针，在中国大陆境内则默认用高德地图，在海外则默认用Google地图；用 Google Geocode 得到地理位置编码，在把地理位置编码提供给高德地图，解决 AMAP API 对直接用英文搜索地名支持很差的问题。不足之处：高德地图无法对海外城市提供路线规划（场景：用户在中国大陆，但旅行计划在在海外城市）；同样的，谷歌地图对中国大陆城市的路线规划的支持也不好。</li><li>部署双端OSS源站同步存放静态资源，并用cdn加速访问; 设置cdn规则，海外用户默认回源 OSS（新加坡），中国大陆用户默认回源OSS（杭州），加快国内外用户访问速度。</li></ol><p><strong>踩坑：</strong></p><ol><li>Project包含多个Module时，用IDEA启动Application类是默认在项目根目录开始的，如果在module目录下定义了环境变量.env文件，是读取不到的。需要把Application类的Working directory改成<script type="math/tex">ModuleFileDir</script></li><li>最麻烦的地方反而是国内网络的问题，有些外部服务和网址国内根本访问不了。用ec2+nginx反代这些外部服务，再用国内cdn加速ec2，这样用户请求外部服务的流程就是 用户 -&gt; cdn边缘节点 -&gt; ec2 -&gt; 外部服务/网址 -&gt; ec2 -&gt; cdn边缘节点 -&gt; 用户，跳过用户直接访问外部服务/网址。</li><li>给国内适配高德太麻烦了。。。本来向直接反代google map，但好像不合规，高德的官方文档又太久旧了，没有关于ts的内容。再加上高德api对英文的适配很差，如果要传中文就要改prompt估计还要改数据库结构，不太好。正好我的服务器在ec2（新加坡），可以让服务器向google map geocode 发起请求，得到英文地址的地理编码，再把地理编码给高德地图，这样就不用重构数据库了。还在前端加了一个小探针，来判断用户是不是在中国大陆：先让用户访问一个很小的谷歌资源，如果超时就默认让用户访问高德地图，能连通就让用户访问谷歌地图，并缓存结果6小时，避免每次都要判断。</li></ol><h2 id="鉴权"><a href="#鉴权" class="headerlink" title="鉴权"></a>鉴权</h2><h3 id="JwtConfig"><a href="#JwtConfig" class="headerlink" title="JwtConfig"></a>JwtConfig</h3><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Configuration</span></span><br><span class="line"><span class="meta">@EnableWebSecurity</span></span><br><span class="line"><span class="meta">@RequiredArgsConstructor</span></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">AuthConfig</span> </span>&#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> JwtFilter jwtFilter;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Bean</span></span><br><span class="line">    <span class="function">PasswordEncoder <span class="title">passwordEncoder</span><span class="params">()</span> </span>&#123;</span><br><span class="line">        <span class="keyword">return</span> <span class="keyword">new</span> BCryptPasswordEncoder();</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Bean</span></span><br><span class="line">    <span class="function">SecurityFilterChain <span class="title">filterChain</span><span class="params">(HttpSecurity http)</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line">        ObjectMapper om = <span class="keyword">new</span> ObjectMapper();</span><br><span class="line"></span><br><span class="line">        http</span><br><span class="line">                .csrf(csrf -&gt; csrf.disable())</span><br><span class="line">                .sessionManagement(sm -&gt; sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))</span><br><span class="line">                .authorizeHttpRequests(auth -&gt; auth</span><br><span class="line">                        .requestMatchers(<span class="string">&quot;/api/register&quot;</span>, <span class="string">&quot;/api/login&quot;</span>, <span class="string">&quot;/api/verify-email&quot;</span>,<span class="string">&quot;/api/forgot-password&quot;</span>,</span><br><span class="line">                                <span class="string">&quot;/api/reset-password&quot;</span>, <span class="string">&quot;/api/resend-verify-email&quot;</span>, <span class="string">&quot;/api/verify-reset-password-email&quot;</span>) </span><br><span class="line">                        .permitAll()</span><br><span class="line">                        .anyRequest().authenticated()</span><br><span class="line">                )</span><br><span class="line">                .exceptionHandling(ex -&gt; ex</span><br><span class="line">                        .authenticationEntryPoint((req, res, e) -&gt; &#123;</span><br><span class="line">                            res.setStatus(HttpStatus.UNAUTHORIZED.value());</span><br><span class="line">                            res.setContentType(<span class="string">&quot;application/json;charset=UTF-8&quot;</span>);</span><br><span class="line">                            res.getWriter().write(om.writeValueAsString(ApiRespond.error(<span class="string">&quot;Not authenticated, please log in first&quot;</span>)));</span><br><span class="line">                        &#125;)</span><br><span class="line">                        .accessDeniedHandler((req, res, e) -&gt; &#123;</span><br><span class="line">                            res.setStatus(HttpStatus.FORBIDDEN.value());</span><br><span class="line">                            res.setContentType(<span class="string">&quot;application/json;charset=UTF-8&quot;</span>);</span><br><span class="line">                            res.getWriter().write(om.writeValueAsString(ApiRespond.error(<span class="string">&quot;You do not have permission to access this resource&quot;</span>)));</span><br><span class="line">                        &#125;)</span><br><span class="line">                )</span><br><span class="line">                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);</span><br><span class="line">        <span class="keyword">return</span> http.build();</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="JwtFilter"><a href="#JwtFilter" class="headerlink" title="JwtFilter"></a>JwtFilter</h3><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Spring Security JWT filter that extracts and validates JWT from request headers</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Slf4j</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="meta">@RequiredArgsConstructor</span></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">JwtFilter</span> <span class="keyword">extends</span> <span class="title">OncePerRequestFilter</span> </span>&#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> JwtUtils jwtUtil;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> UserRepository userRepository;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Filter pass-through logic</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> request</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> response</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> chain</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@throws</span> ServletException</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@throws</span> IOException</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@Override</span></span><br><span class="line">    <span class="function"><span class="keyword">protected</span> <span class="keyword">void</span> <span class="title">doFilterInternal</span><span class="params">(HttpServletRequest request, HttpServletResponse response, FilterChain chain)</span></span></span><br><span class="line"><span class="function">            <span class="keyword">throws</span> ServletException, IOException </span>&#123;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// Extract and validate JWT from Authorization header: Bearer &lt;JWT&gt;</span></span><br><span class="line">        String header = request.getHeader(<span class="string">&quot;Authorization&quot;</span>);</span><br><span class="line">        <span class="keyword">if</span> (header != <span class="keyword">null</span> &amp;&amp; header.startsWith(<span class="string">&quot;Bearer &quot;</span>)) &#123;</span><br><span class="line">            <span class="comment">// Extract JWT from Bearer &lt;JWT&gt;</span></span><br><span class="line">            String token = header.substring(<span class="number">7</span>);</span><br><span class="line">            <span class="keyword">try</span> &#123;</span><br><span class="line">                Claims claims = jwtUtil.parse(token);</span><br><span class="line">                String subject = claims.getSubject();</span><br><span class="line">                <span class="keyword">if</span> (subject != <span class="keyword">null</span> &amp;&amp; SecurityContextHolder.getContext().getAuthentication() == <span class="keyword">null</span>) &#123;</span><br><span class="line">                    Long userId = Long.valueOf(subject);</span><br><span class="line">                    Integer tokenVersion = claims.get(<span class="string">&quot;version&quot;</span>, Integer.class);</span><br><span class="line"></span><br><span class="line">                    User user = userRepository.findById(userId).orElse(<span class="keyword">null</span>);</span><br><span class="line">                    <span class="comment">// Check whether token version matches (mismatch after password change)</span></span><br><span class="line">                    <span class="keyword">if</span> (user != <span class="keyword">null</span> &amp;&amp; tokenVersion.equals(user.getTokenVersion())) &#123;</span><br><span class="line">                        <span class="comment">// Set the subject content (String userId) as the JWT principal</span></span><br><span class="line">                        <span class="comment">// In controllers, you can get the principal from the authenticated user&#x27;s token via @AuthenticationPrincipal (String userId)</span></span><br><span class="line">                        UsernamePasswordAuthenticationToken auth =</span><br><span class="line">                                <span class="keyword">new</span> UsernamePasswordAuthenticationToken(</span><br><span class="line">                                        subject, <span class="keyword">null</span>, Collections.emptyList());</span><br><span class="line">                        SecurityContextHolder.getContext().setAuthentication(auth); <span class="comment">// Authenticated</span></span><br><span class="line">                    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">                        SecurityContextHolder.clearContext();</span><br><span class="line">                    &#125;</span><br><span class="line">                &#125;</span><br><span class="line">            &#125; <span class="keyword">catch</span> (RuntimeException e) &#123;</span><br><span class="line">                <span class="comment">// If parsing fails, do not set Authentication; the request will be rejected at controller due to unauthenticated</span></span><br><span class="line">                SecurityContextHolder.clearContext();</span><br><span class="line">                log.debug(<span class="string">&quot;Invalid token&quot;</span>, e);</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">        chain.doFilter(request, response);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="JwtUtils"><a href="#JwtUtils" class="headerlink" title="JwtUtils"></a>JwtUtils</h3><p>见 <strong>工具类</strong></p><h2 id="工具类"><a href="#工具类" class="headerlink" title="工具类"></a>工具类</h2><h3 id="AWS-S3-Utils"><a href="#AWS-S3-Utils" class="headerlink" title="AWS S3 Utils"></a>AWS S3 Utils</h3><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br><span class="line">124</span><br><span class="line">125</span><br><span class="line">126</span><br><span class="line">127</span><br><span class="line">128</span><br><span class="line">129</span><br><span class="line">130</span><br><span class="line">131</span><br><span class="line">132</span><br><span class="line">133</span><br><span class="line">134</span><br><span class="line">135</span><br><span class="line">136</span><br><span class="line">137</span><br><span class="line">138</span><br><span class="line">139</span><br><span class="line">140</span><br><span class="line">141</span><br><span class="line">142</span><br><span class="line">143</span><br><span class="line">144</span><br><span class="line">145</span><br><span class="line">146</span><br><span class="line">147</span><br><span class="line">148</span><br><span class="line">149</span><br><span class="line">150</span><br><span class="line">151</span><br><span class="line">152</span><br><span class="line">153</span><br><span class="line">154</span><br><span class="line">155</span><br><span class="line">156</span><br><span class="line">157</span><br><span class="line">158</span><br><span class="line">159</span><br><span class="line">160</span><br><span class="line">161</span><br><span class="line">162</span><br><span class="line">163</span><br><span class="line">164</span><br><span class="line">165</span><br><span class="line">166</span><br><span class="line">167</span><br><span class="line">168</span><br><span class="line">169</span><br><span class="line">170</span><br><span class="line">171</span><br><span class="line">172</span><br><span class="line">173</span><br><span class="line">174</span><br><span class="line">175</span><br><span class="line">176</span><br><span class="line">177</span><br><span class="line">178</span><br><span class="line">179</span><br><span class="line">180</span><br><span class="line">181</span><br><span class="line">182</span><br><span class="line">183</span><br><span class="line">184</span><br><span class="line">185</span><br><span class="line">186</span><br><span class="line">187</span><br><span class="line">188</span><br><span class="line">189</span><br><span class="line">190</span><br><span class="line">191</span><br><span class="line">192</span><br><span class="line">193</span><br><span class="line">194</span><br><span class="line">195</span><br><span class="line">196</span><br><span class="line">197</span><br><span class="line">198</span><br><span class="line">199</span><br><span class="line">200</span><br><span class="line">201</span><br><span class="line">202</span><br><span class="line">203</span><br><span class="line">204</span><br><span class="line">205</span><br><span class="line">206</span><br><span class="line">207</span><br><span class="line">208</span><br><span class="line">209</span><br><span class="line">210</span><br><span class="line">211</span><br><span class="line">212</span><br><span class="line">213</span><br><span class="line">214</span><br><span class="line">215</span><br><span class="line">216</span><br><span class="line">217</span><br><span class="line">218</span><br><span class="line">219</span><br><span class="line">220</span><br><span class="line">221</span><br><span class="line">222</span><br><span class="line">223</span><br><span class="line">224</span><br><span class="line">225</span><br><span class="line">226</span><br><span class="line">227</span><br><span class="line">228</span><br><span class="line">229</span><br><span class="line">230</span><br><span class="line">231</span><br><span class="line">232</span><br><span class="line">233</span><br><span class="line">234</span><br><span class="line">235</span><br><span class="line">236</span><br><span class="line">237</span><br><span class="line">238</span><br><span class="line">239</span><br><span class="line">240</span><br><span class="line">241</span><br><span class="line">242</span><br><span class="line">243</span><br><span class="line">244</span><br><span class="line">245</span><br><span class="line">246</span><br><span class="line">247</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * AWS S3 utils</span></span><br><span class="line"><span class="comment"> * See AWS SDK for Java v2 official documentation</span></span><br><span class="line"><span class="comment"> * https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/java_s3_code_examples.html</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Slf4j</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">AwsS3Utils</span> </span>&#123;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> S3Client s3Client;</span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="title">AwsS3Utils</span><span class="params">(<span class="meta">@Value(&quot;$&#123;aws.accesskeyId&#125;&quot;)</span> String accesskeyId,</span></span></span><br><span class="line"><span class="params"><span class="function">                      <span class="meta">@Value(&quot;$&#123;aws.secretAccessKey&#125;&quot;)</span> String secretAccessKey,</span></span></span><br><span class="line"><span class="params"><span class="function">                      <span class="meta">@Value(&quot;$&#123;aws.region&#125;&quot;)</span> String region)</span> </span>&#123;</span><br><span class="line">        AwsBasicCredentials creds = AwsBasicCredentials.create(accesskeyId, secretAccessKey);</span><br><span class="line">        <span class="keyword">this</span>.s3Client = S3Client.builder()</span><br><span class="line">                .region(Region.of(region))</span><br><span class="line">                .credentialsProvider(StaticCredentialsProvider.create(creds))</span><br><span class="line">                .build();</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="meta">@PreDestroy</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">close</span><span class="params">()</span> </span>&#123;</span><br><span class="line">        <span class="keyword">try</span> &#123;</span><br><span class="line">            s3Client.close();</span><br><span class="line">        &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">            log.debug(<span class="string">&quot;Ignore s3Client close error&quot;</span>, e);</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Value(&quot;$&#123;aws.s3.bucket-name&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String bucketName;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Value(&quot;$&#123;aws.s3.dir-name&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String dirName;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Value(&quot;$&#123;aws.s3.cdn&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String cdn;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 1. S3 single file upload</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> in               file input stream</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> originalFilename original filename</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> Return URL (https://&#123;cdn&#125;/&#123;dir/yyyy/MM/xxx.png&#125;)</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> String <span class="title">upload</span><span class="params">(InputStream in, String originalFilename, <span class="keyword">boolean</span> isAvatar)</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line">        <span class="comment">// If it is an avatar, put it under elec5620-stage2/avatars</span></span><br><span class="line">        String baseDir = isAvatar ? (dirName + <span class="string">&quot;/avatars&quot;</span>) : dirName;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// Directory and file name: rewrite the filename</span></span><br><span class="line">        String dir = LocalDate.now().format(DateTimeFormatter.ofPattern(<span class="string">&quot;yyyy/MM&quot;</span>));</span><br><span class="line">        String suffix = originalFilename.substring(originalFilename.lastIndexOf(<span class="string">&quot;.&quot;</span>));</span><br><span class="line">        String newFileName = UUID.randomUUID().toString().replace(<span class="string">&quot;-&quot;</span>, <span class="string">&quot;&quot;</span>) + suffix;</span><br><span class="line">        String objectKey = baseDir + <span class="string">&quot;/&quot;</span> + dir + <span class="string">&quot;/&quot;</span> + newFileName;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// Read into byte[] to get content-length</span></span><br><span class="line">        ByteArrayOutputStream bos = <span class="keyword">new</span> ByteArrayOutputStream(Math.max(<span class="number">32</span> * <span class="number">1024</span>, in.available()));</span><br><span class="line">        in.transferTo(bos);</span><br><span class="line">        <span class="keyword">byte</span>[] bytes = bos.toByteArray();</span><br><span class="line"></span><br><span class="line">        String contentType = <span class="keyword">switch</span> (suffix) &#123;</span><br><span class="line">            <span class="keyword">case</span> <span class="string">&quot;.jpg&quot;</span>, <span class="string">&quot;.jpeg&quot;</span> -&gt; <span class="string">&quot;image/jpeg&quot;</span>;</span><br><span class="line">            <span class="keyword">case</span> <span class="string">&quot;.png&quot;</span> -&gt; <span class="string">&quot;image/png&quot;</span>;</span><br><span class="line">            <span class="keyword">case</span> <span class="string">&quot;.gif&quot;</span> -&gt; <span class="string">&quot;image/gif&quot;</span>;</span><br><span class="line">            <span class="keyword">case</span> <span class="string">&quot;.svg&quot;</span> -&gt; <span class="string">&quot;image/svg+xml&quot;</span>;</span><br><span class="line">            <span class="keyword">case</span> <span class="string">&quot;.webp&quot;</span> -&gt; <span class="string">&quot;image/webp&quot;</span>;</span><br><span class="line">            <span class="keyword">default</span> -&gt; <span class="string">&quot;application/octet-stream&quot;</span>; <span class="comment">// Default type when file type is unknown</span></span><br><span class="line">        &#125;;</span><br><span class="line"></span><br><span class="line">        PutObjectRequest putReq = PutObjectRequest.builder()</span><br><span class="line">                .bucket(bucketName)</span><br><span class="line">                .key(objectKey)</span><br><span class="line">                .contentType(contentType)</span><br><span class="line">                .build();</span><br><span class="line"></span><br><span class="line">        s3Client.putObject(putReq, RequestBody.fromBytes(bytes));</span><br><span class="line"></span><br><span class="line">        <span class="keyword">return</span> <span class="string">&quot;https://&quot;</span> + cdn + <span class="string">&quot;/&quot;</span> + objectKey;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 1.1 Download image from external URL to memory, then upload, and return final URL (AWS S3)</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> imageUrl external URL</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> Return URL (https://&#123;cdn&#125;/&#123;dir/yyyy/MM/xxx.png&#125;)</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> String <span class="title">uploadFromUrl</span><span class="params">(String imageUrl, <span class="keyword">boolean</span> isAvatar)</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line"></span><br><span class="line">        HttpClient client = HttpClient.newBuilder()</span><br><span class="line">                .followRedirects(HttpClient.Redirect.NORMAL)</span><br><span class="line">                .connectTimeout(Duration.ofSeconds(<span class="number">10</span>))</span><br><span class="line">                .build();</span><br><span class="line"></span><br><span class="line">        HttpRequest request = HttpRequest.newBuilder(URI.create(imageUrl))</span><br><span class="line">                .timeout(Duration.ofSeconds(<span class="number">20</span>))</span><br><span class="line">                .GET()</span><br><span class="line">                .build();</span><br><span class="line"></span><br><span class="line">        HttpResponse&lt;InputStream&gt; response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());</span><br><span class="line">        <span class="keyword">if</span> (response.statusCode() / <span class="number">100</span> != <span class="number">2</span>) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> IllegalArgumentException(<span class="string">&quot;Fetch image failed, http status=&quot;</span> + response.statusCode());</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        String contentType = response.headers().firstValue(<span class="string">&quot;Content-Type&quot;</span>).orElse(<span class="string">&quot;&quot;</span>);</span><br><span class="line">        <span class="keyword">if</span> (!contentType.isEmpty() &amp;&amp; !contentType.toLowerCase(Locale.ROOT).startsWith(<span class="string">&quot;image/&quot;</span>)) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> IllegalArgumentException(<span class="string">&quot;Unsupported content-type: &quot;</span> + contentType);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">long</span> maxSize = <span class="number">10</span> * <span class="number">1024</span> * <span class="number">1024L</span>;</span><br><span class="line">        <span class="keyword">long</span> contentLength = response.headers().firstValueAsLong(<span class="string">&quot;Content-Length&quot;</span>).orElse(-<span class="number">1</span>);</span><br><span class="line">        <span class="keyword">if</span> (contentLength &gt; maxSize) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> IllegalArgumentException(<span class="string">&quot;File too large, max size is 10 MB&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// Infer file suffix</span></span><br><span class="line">        String suffix = <span class="keyword">null</span>;</span><br><span class="line">        String ct = contentType.toLowerCase(Locale.ROOT);</span><br><span class="line">        <span class="keyword">if</span> (ct.contains(<span class="string">&quot;jpeg&quot;</span>) || ct.contains(<span class="string">&quot;jpg&quot;</span>)) suffix = <span class="string">&quot;.jpg&quot;</span>;</span><br><span class="line">        <span class="keyword">else</span> <span class="keyword">if</span> (ct.contains(<span class="string">&quot;png&quot;</span>)) suffix = <span class="string">&quot;.png&quot;</span>;</span><br><span class="line">        <span class="keyword">else</span> <span class="keyword">if</span> (ct.contains(<span class="string">&quot;gif&quot;</span>)) suffix = <span class="string">&quot;.gif&quot;</span>;</span><br><span class="line">        <span class="keyword">else</span> <span class="keyword">if</span> (ct.contains(<span class="string">&quot;svg&quot;</span>)) suffix = <span class="string">&quot;.svg&quot;</span>;</span><br><span class="line">        <span class="keyword">else</span> <span class="keyword">if</span> (ct.contains(<span class="string">&quot;webp&quot;</span>)) suffix = <span class="string">&quot;.webp&quot;</span>;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">if</span> (suffix == <span class="keyword">null</span>) &#123;</span><br><span class="line">            String path = URI.create(imageUrl).getPath();</span><br><span class="line">            <span class="keyword">int</span> dot = path.lastIndexOf(<span class="string">&#x27;.&#x27;</span>);</span><br><span class="line">            <span class="keyword">if</span> (dot != -<span class="number">1</span> &amp;&amp; dot &lt; path.length() - <span class="number">1</span>) &#123;</span><br><span class="line">                String ext = path.substring(dot + <span class="number">1</span>).toLowerCase(Locale.ROOT);</span><br><span class="line">                <span class="keyword">if</span> (ext.matches(<span class="string">&quot;jpg|jpeg|png|gif|svg|webp&quot;</span>)) &#123;</span><br><span class="line">                    suffix = <span class="string">&quot;.&quot;</span> + (ext.equals(<span class="string">&quot;jpeg&quot;</span>) ? <span class="string">&quot;jpg&quot;</span> : ext);</span><br><span class="line">                &#125;</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">if</span> (suffix == <span class="keyword">null</span>) suffix = <span class="string">&quot;.jpg&quot;</span>;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// Read into memory and enforce 10 MB limit</span></span><br><span class="line">        <span class="keyword">try</span> (InputStream in = response.body(); ByteArrayOutputStream bos = <span class="keyword">new</span> ByteArrayOutputStream()) &#123;</span><br><span class="line">            <span class="keyword">byte</span>[] buf = <span class="keyword">new</span> <span class="keyword">byte</span>[<span class="number">8192</span>];</span><br><span class="line">            <span class="keyword">long</span> total = <span class="number">0</span>;</span><br><span class="line">            <span class="keyword">int</span> n;</span><br><span class="line">            <span class="keyword">while</span> ((n = in.read(buf)) != -<span class="number">1</span>) &#123;</span><br><span class="line">                total += n;</span><br><span class="line">                <span class="keyword">if</span> (total &gt; maxSize) &#123;</span><br><span class="line">                    <span class="keyword">throw</span> <span class="keyword">new</span> IllegalArgumentException(<span class="string">&quot;File too large, max size is 10 MB&quot;</span>);</span><br><span class="line">                &#125;</span><br><span class="line">                bos.write(buf, <span class="number">0</span>, n);</span><br><span class="line">            &#125;</span><br><span class="line">            String s3Url = upload(<span class="keyword">new</span> ByteArrayInputStream(bos.toByteArray()), <span class="string">&quot;temp&quot;</span> + suffix, isAvatar);</span><br><span class="line">            log.info(<span class="string">&quot;Upload from &#123;&#125; to &#123;&#125;&quot;</span>, imageUrl, s3Url);</span><br><span class="line">            <span class="keyword">return</span> s3Url;</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 2. List files in AWS S3 with the specified prefix (applicable when file count ≤ 1000)</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> preFix  Prefix (e.g., elec5620-stage2/2025)</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> size    Maximum number to list (up to 1000)</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> List&lt;String&gt; <span class="title">listFiles</span><span class="params">(String preFix, <span class="keyword">int</span> size)</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line">        <span class="keyword">if</span> (size &gt; <span class="number">1000</span>) &#123;</span><br><span class="line">            size = <span class="number">1000</span>;</span><br><span class="line">            log.warn(<span class="string">&quot;A maximum of 1000 files is allowed, it has been automatically adjusted to 1000&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        List&lt;String&gt; fileList = <span class="keyword">new</span> ArrayList&lt;&gt;();</span><br><span class="line">        ListObjectsV2Response resp = s3Client.listObjectsV2(ListObjectsV2Request.builder()</span><br><span class="line">                .bucket(bucketName)</span><br><span class="line">                .prefix(preFix)</span><br><span class="line">                .maxKeys(size)</span><br><span class="line">                .build());</span><br><span class="line">        List&lt;S3Object&gt; contents = resp.contents();</span><br><span class="line">        <span class="keyword">if</span> (ObjectUtil.isNotEmpty(contents)) &#123;</span><br><span class="line">            fileList = contents.stream().map(S3Object::key).toList();</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">return</span> fileList;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 3. Paginated listing of files in AWS S3 with the specified prefix (applicable when file count &gt; 1000)</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> preFix   Prefix</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> pageSize Page size (max 1000)</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> List&lt;String&gt; <span class="title">listPageAllFiles</span><span class="params">(String preFix, <span class="keyword">int</span> pageSize)</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line">        <span class="keyword">if</span> (pageSize &gt; <span class="number">1000</span>) &#123;</span><br><span class="line">            pageSize = <span class="number">1000</span>;</span><br><span class="line">            log.warn(<span class="string">&quot;A maximum of 1000 files every time is allowed, it has been automatically adjusted to 1000&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        List&lt;String&gt; fileList = <span class="keyword">new</span> ArrayList&lt;&gt;();</span><br><span class="line">        String continuationToken = <span class="keyword">null</span>;</span><br><span class="line"></span><br><span class="line">        ListObjectsV2Response resp;</span><br><span class="line">        <span class="keyword">do</span> &#123;</span><br><span class="line">            <span class="keyword">var</span> builder = ListObjectsV2Request.builder()</span><br><span class="line">                    .bucket(bucketName)</span><br><span class="line">                    .prefix(preFix)</span><br><span class="line">                    .maxKeys(pageSize);</span><br><span class="line">            <span class="keyword">if</span> (continuationToken != <span class="keyword">null</span>) builder.continuationToken(continuationToken);</span><br><span class="line"></span><br><span class="line">            resp = s3Client.listObjectsV2(builder.build());</span><br><span class="line"></span><br><span class="line">            List&lt;S3Object&gt; contents = resp.contents();</span><br><span class="line">            <span class="keyword">if</span> (ObjectUtil.isNotEmpty(contents)) &#123;</span><br><span class="line">                fileList.addAll(contents.stream().map(S3Object::key).toList());</span><br><span class="line">            &#125;</span><br><span class="line">            continuationToken = resp.nextContinuationToken();</span><br><span class="line">        &#125; <span class="keyword">while</span> (resp.isTruncated());</span><br><span class="line"></span><br><span class="line">        <span class="keyword">return</span> fileList;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 4. Delete a single file</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> objectKey File key (without bucket name), e.g.: elec5620-stage2/2025/06/xxx.png</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">deleteFile</span><span class="params">(String objectKey)</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line">        s3Client.deleteObject(DeleteObjectRequest.builder()</span><br><span class="line">                .bucket(bucketName)</span><br><span class="line">                .key(objectKey)</span><br><span class="line">                .build());</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * 5. Batch delete multiple files (up to 1000 at a time; split into batches if exceeded)</span></span><br><span class="line"><span class="comment">     *</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> objectKeys List of keys to delete</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">batchDeleteFiles</span><span class="params">(List&lt;String&gt; objectKeys)</span> <span class="keyword">throws</span> Exception </span>&#123;</span><br><span class="line">        <span class="keyword">for</span> (<span class="keyword">int</span> i = <span class="number">0</span>; i &lt; objectKeys.size(); i += <span class="number">1000</span>) &#123;</span><br><span class="line">            <span class="keyword">int</span> end = Math.min(i + <span class="number">1000</span>, objectKeys.size());</span><br><span class="line">            List&lt;String&gt; subList = objectKeys.subList(i, end);</span><br><span class="line">            log.info(<span class="string">&quot;Batch deleting files, current batch: &#123;&#125;, file count: &#123;&#125;&quot;</span>, (i / <span class="number">1000</span>) + <span class="number">1</span>, subList.size());</span><br><span class="line"></span><br><span class="line">            List&lt;ObjectIdentifier&gt; ids = <span class="keyword">new</span> ArrayList&lt;&gt;(subList.size());</span><br><span class="line">            <span class="keyword">for</span> (String key : subList) &#123;</span><br><span class="line">                ids.add(ObjectIdentifier.builder().key(key).build());</span><br><span class="line">            &#125;</span><br><span class="line"></span><br><span class="line">            s3Client.deleteObjects(DeleteObjectsRequest.builder()</span><br><span class="line">                    .bucket(bucketName)</span><br><span class="line">                    .delete(Delete.builder().objects(ids).quiet(<span class="keyword">true</span>).build())</span><br><span class="line">                    .build());</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="JWT-Utils"><a href="#JWT-Utils" class="headerlink" title="JWT Utils"></a>JWT Utils</h3><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * JWT utility class</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="meta">@Slf4j</span></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">JwtUtils</span> </span>&#123;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> Key key;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> <span class="keyword">long</span> expireTime;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Authentication expires after 60 minutes</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="title">JwtUtils</span><span class="params">(<span class="meta">@Value(&quot;$&#123;jwt.token.secret&#125;&quot;)</span> String secret,</span></span></span><br><span class="line"><span class="params"><span class="function">                    <span class="meta">@Value(&quot;$&#123;jwt.token.expireTime&#125;&quot;)</span> <span class="keyword">long</span> expireTime)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">this</span>.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));</span><br><span class="line">        <span class="keyword">this</span>.expireTime = expireTime;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Generate JWT</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> subject</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> claims</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span></span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> String <span class="title">generateJwt</span><span class="params">(String subject, Map&lt;String, Object&gt; claims)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">long</span> now = System.currentTimeMillis();</span><br><span class="line">        <span class="keyword">return</span> Jwts.builder()</span><br><span class="line">                .setSubject(subject) <span class="comment">// subject is the user id (String)</span></span><br><span class="line">                .addClaims(claims == <span class="keyword">null</span> ? Map.of() : claims) <span class="comment">// claims include username and email</span></span><br><span class="line">                .setIssuedAt(<span class="keyword">new</span> Date(now))</span><br><span class="line">                .setExpiration(<span class="keyword">new</span> Date(now + expireTime))</span><br><span class="line">                .signWith(key, SignatureAlgorithm.HS256)</span><br><span class="line">                .compact();</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Parse JWT</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> token</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span></span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> Claims <span class="title">parse</span><span class="params">(String token)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">try</span> &#123;</span><br><span class="line">            Claims claims = Jwts.parserBuilder()</span><br><span class="line">                    .setSigningKey(key)</span><br><span class="line">                    .build()</span><br><span class="line">                    .parseClaimsJws(token)</span><br><span class="line">                    .getBody();</span><br><span class="line"></span><br><span class="line">            <span class="keyword">long</span> secondsLeft = Math.max(<span class="number">0</span>, (claims.getExpiration().getTime() - System.currentTimeMillis()) / <span class="number">1000</span>);</span><br><span class="line">            <span class="keyword">if</span> (secondsLeft &lt;= <span class="number">600</span>) &#123;</span><br><span class="line">                log.warn(<span class="string">&quot;Token has less than 10 minutes remaining: &#123;&#125; seconds&quot;</span>, secondsLeft);</span><br><span class="line">            &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">                log.debug(<span class="string">&quot;Token remaining: &#123;&#125; seconds&quot;</span>, secondsLeft);</span><br><span class="line">            &#125;</span><br><span class="line">            <span class="keyword">return</span> claims;</span><br><span class="line"></span><br><span class="line">        &#125; <span class="keyword">catch</span> (ExpiredJwtException e) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> BusinessException(<span class="string">&quot;Token has expired&quot;</span>);</span><br><span class="line">        &#125; <span class="keyword">catch</span> (JwtException e) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> BusinessException(<span class="string">&quot;Invalid token&quot;</span>);</span><br><span class="line">        &#125; <span class="keyword">catch</span> (IllegalArgumentException e) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> BusinessException(<span class="string">&quot;Token is null or wrong format&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="SendGrid-Email-API-Utils"><a href="#SendGrid-Email-API-Utils" class="headerlink" title="SendGrid Email API Utils"></a>SendGrid Email API Utils</h3><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * SendGrid Email API Utils.</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="meta">@Slf4j</span></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">SendGridUtils</span> </span>&#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> SendGrid client;</span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="title">SendGridUtils</span><span class="params">(<span class="meta">@Value(&quot;$&#123;sendgrid.api-key&#125;&quot;)</span> String apiKey)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">this</span>.client = <span class="keyword">new</span> SendGrid(apiKey);</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="meta">@PreDestroy</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">close</span><span class="params">()</span> </span>&#123;&#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Value(&quot;$&#123;sendgrid.from&#125;&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> String from;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Send HTML email.</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> to</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> subject</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> html</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">sendHtml</span><span class="params">(String to, String subject, String html)</span> </span>&#123;</span><br><span class="line">        Mail mail = <span class="keyword">new</span> Mail();</span><br><span class="line">        mail.setFrom(<span class="keyword">new</span> Email(from));</span><br><span class="line">        mail.setSubject(subject);</span><br><span class="line"></span><br><span class="line">        Personalization p = <span class="keyword">new</span> Personalization();</span><br><span class="line">        p.addTo(<span class="keyword">new</span> Email(to));</span><br><span class="line">        mail.addPersonalization(p);</span><br><span class="line"></span><br><span class="line">        mail.addContent(<span class="keyword">new</span> Content(<span class="string">&quot;text/html&quot;</span>, html));</span><br><span class="line"></span><br><span class="line">        Request req = <span class="keyword">new</span> Request();</span><br><span class="line">        <span class="keyword">try</span> &#123;</span><br><span class="line">            req.setMethod(Method.POST);</span><br><span class="line">            req.setEndpoint(<span class="string">&quot;mail/send&quot;</span>);</span><br><span class="line">            req.setBody(mail.build());</span><br><span class="line">            Response resp = client.api(req);</span><br><span class="line">            <span class="keyword">int</span> code = resp.getStatusCode();</span><br><span class="line">            <span class="keyword">if</span> (code &gt;= <span class="number">400</span>) &#123;</span><br><span class="line">                <span class="keyword">throw</span> <span class="keyword">new</span> RuntimeException(<span class="string">&quot;SendGrid error: &quot;</span> + code + <span class="string">&quot; - &quot;</span> + resp.getBody());</span><br><span class="line">            &#125;</span><br><span class="line">        &#125; <span class="keyword">catch</span> (Exception e) &#123;</span><br><span class="line">            log.error(<span class="string">&quot;SendGrid sendHtml failed: to=&#123;&#125;, subject=&#123;&#125;&quot;</span>, to, subject, e);</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> RuntimeException(<span class="string">&quot;Email send failed&quot;</span>, e);</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Send verify email.</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> to</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> verifyLink</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">sendVerifyEmail</span><span class="params">(String to, String verifyLink)</span> </span>&#123;</span><br><span class="line">        String subject = <span class="string">&quot;Verify your email&quot;</span>;</span><br><span class="line">        String html = <span class="string">&quot;&quot;</span><span class="string">&quot;</span></span><br><span class="line"><span class="string">          &lt;div style=&quot;</span>font-family:sans-serif<span class="string">&quot;&gt;</span></span><br><span class="line"><span class="string">            &lt;h2&gt;Verify your email&lt;/h2&gt;</span></span><br><span class="line"><span class="string">            &lt;p&gt;Click the button below to verify your email address:&lt;/p&gt;</span></span><br><span class="line"><span class="string">            &lt;p&gt;&lt;a href=&quot;</span>%s<span class="string">&quot;&gt;Verify Email&lt;/a&gt;&lt;/p&gt;</span></span><br><span class="line"><span class="string">            &lt;p&gt;If the button doesn&#x27;t work, copy this URL:&lt;br/&gt;%s&lt;/p&gt;</span></span><br><span class="line"><span class="string">            &lt;p&gt;This link expires in 2 hours.&lt;/p&gt;</span></span><br><span class="line"><span class="string">          &lt;/div&gt;</span></span><br><span class="line"><span class="string">        &quot;</span><span class="string">&quot;&quot;</span>.formatted(verifyLink, verifyLink);</span><br><span class="line">        sendHtml(to, subject, html);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Send reset password email.</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> to</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> resetLink</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">sendResetEmail</span><span class="params">(String to, String resetLink)</span> </span>&#123;</span><br><span class="line">        String subject = <span class="string">&quot;Reset your password&quot;</span>;</span><br><span class="line">        String html = <span class="string">&quot;&quot;</span><span class="string">&quot;</span></span><br><span class="line"><span class="string">          &lt;div style=&quot;</span>font-family:sans-serif<span class="string">&quot;&gt;</span></span><br><span class="line"><span class="string">            &lt;h2&gt;Reset your password&lt;/h2&gt;</span></span><br><span class="line"><span class="string">            &lt;p&gt;If you didn&#x27;t request this, you can ignore this email.&lt;/p&gt;</span></span><br><span class="line"><span class="string">            &lt;p&gt;&lt;a href=&quot;</span>%s<span class="string">&quot;&gt;Reset Password&lt;/a&gt;&lt;/p&gt;</span></span><br><span class="line"><span class="string">            &lt;p&gt;If the button doesn&#x27;t work, copy this URL:&lt;br/&gt;%s&lt;/p&gt;</span></span><br><span class="line"><span class="string">            &lt;p&gt;This link expires in 30 minutes.&lt;/p&gt;</span></span><br><span class="line"><span class="string">          &lt;/div&gt;</span></span><br><span class="line"><span class="string">        &quot;</span><span class="string">&quot;&quot;</span>.formatted(resetLink, resetLink);</span><br><span class="line">        sendHtml(to, subject, html);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Send change email verification email.</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> to</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> changeLink</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="keyword">void</span> <span class="title">sendChangeEmail</span><span class="params">(String to, String changeLink)</span> </span>&#123;</span><br><span class="line">        String subject = <span class="string">&quot;Change your email&quot;</span>;</span><br><span class="line">        String html = <span class="string">&quot;&quot;</span><span class="string">&quot;</span></span><br><span class="line"><span class="string">          &lt;div style=&quot;</span>font-family:sans-serif<span class="string">&quot;&gt;</span></span><br><span class="line"><span class="string">            &lt;h2&gt;Change your email&lt;/h2&gt;</span></span><br><span class="line"><span class="string">            &lt;p&gt;Click the button below to change your email address:&lt;/p&gt;</span></span><br><span class="line"><span class="string">            &lt;p&gt;&lt;a href=&quot;</span>%s<span class="string">&quot;&gt;Change Email&lt;/a&gt;&lt;/p&gt;</span></span><br><span class="line"><span class="string">            &lt;p&gt;If the button doesn&#x27;t work, copy this URL:&lt;br/&gt;%s&lt;/p&gt;</span></span><br><span class="line"><span class="string">            &lt;p&gt;This link expires in 30 minutes.&lt;/p&gt;</span></span><br><span class="line"><span class="string">          &lt;/div&gt;</span></span><br><span class="line"><span class="string">        &quot;</span><span class="string">&quot;&quot;</span>.formatted(changeLink, changeLink);</span><br><span class="line">        sendHtml(to, subject, html);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="Unsplash-Image-Utils"><a href="#Unsplash-Image-Utils" class="headerlink" title="Unsplash Image Utils"></a>Unsplash Image Utils</h3><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Unsplash Image Utils</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Slf4j</span></span><br><span class="line"><span class="meta">@Component</span></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">UnsplashImgUtils</span> </span>&#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">final</span> WebClient unsplashWebClient;</span><br><span class="line"></span><br><span class="line">    <span class="function"><span class="keyword">public</span> <span class="title">UnsplashImgUtils</span><span class="params">(WebClient.Builder builder,</span></span></span><br><span class="line"><span class="params"><span class="function">                            <span class="meta">@Value(&quot;$&#123;unsplash.access-key&#125;&quot;)</span> String accessKey)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">this</span>.unsplashWebClient = builder</span><br><span class="line">                .baseUrl(<span class="string">&quot;https://api.unsplash.com&quot;</span>)</span><br><span class="line">                .defaultHeader(<span class="string">&quot;Authorization&quot;</span>, <span class="string">&quot;Client-ID &quot;</span> + accessKey)</span><br><span class="line">                .build();</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Search N image urls.</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> q search keyword</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> n number of images to be returned</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> width width of the image</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> height height of the image</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> a list of Aws S3 URLs</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> List&lt;String&gt; <span class="title">getImgUrls</span><span class="params">(String q, <span class="keyword">int</span> n, <span class="keyword">int</span> width, <span class="keyword">int</span> height)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">return</span> searchNImg(q, n).stream()</span><br><span class="line">                .map(photo -&gt; imgUrlFormat(photo, width, height)).toList();</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Search N image urls (default size: 1600x900).</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> q search keyword</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@param</span> n number of images to be returned</span></span><br><span class="line"><span class="comment">     * <span class="doctag">@return</span> a list of Aws S3 URLs</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> List&lt;String&gt; <span class="title">getImgUrls</span><span class="params">(String q, <span class="keyword">int</span> n)</span></span>&#123;</span><br><span class="line">        <span class="keyword">return</span> searchNImg(q, n).stream()</span><br><span class="line">                .map(photo -&gt; imgUrlFormat(photo, <span class="number">1600</span>, <span class="number">900</span>)).toList();</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Get N image Details (JSON) from Unsplash API.</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@SuppressWarnings(&quot;unchecked&quot;)</span></span><br><span class="line">    <span class="keyword">private</span> List&lt;Map&lt;String, Object&gt;&gt; searchNImg(String query, <span class="keyword">int</span> n) &#123;</span><br><span class="line">        <span class="keyword">if</span>(n &gt; <span class="number">10</span>)&#123;</span><br><span class="line">            n = <span class="number">10</span>;</span><br><span class="line">            log.warn(<span class="string">&quot;maximum number is 10, automatically adjusted to 10&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">if</span>(n &lt; <span class="number">1</span>)&#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> IllegalArgumentException(<span class="string">&quot;number must be greater than 0&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">if</span> (query == <span class="keyword">null</span> || query.isBlank()) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> IllegalArgumentException(<span class="string">&quot;query must not be blank&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">final</span> <span class="keyword">int</span> count = n;</span><br><span class="line">        Map&lt;String, Object&gt; body = unsplashWebClient.get()</span><br><span class="line">                .uri(uri -&gt; uri.path(<span class="string">&quot;/search/photos&quot;</span>)</span><br><span class="line">                        .queryParam(<span class="string">&quot;query&quot;</span>, query)</span><br><span class="line">                        .queryParam(<span class="string">&quot;per_page&quot;</span>, count)</span><br><span class="line">                        .build())</span><br><span class="line">                .accept(MediaType.APPLICATION_JSON)</span><br><span class="line">                .retrieve()</span><br><span class="line">                .bodyToMono(Map.class)</span><br><span class="line">                .block();</span><br><span class="line"></span><br><span class="line">        List&lt;Map&lt;String, Object&gt;&gt; results =</span><br><span class="line">                (List&lt;Map&lt;String, Object&gt;&gt;) (body != <span class="keyword">null</span> ? body.getOrDefault(<span class="string">&quot;results&quot;</span>, List.of()) : List.of());</span><br><span class="line"></span><br><span class="line">        <span class="keyword">if</span> (results.isEmpty()) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> NoSuchElementException(<span class="string">&quot;Unsplash has no result for: &quot;</span> + query);</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">return</span> results;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Generate url and format the img.</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@SuppressWarnings(&quot;unchecked&quot;)</span></span><br><span class="line">    <span class="function"><span class="keyword">private</span> String <span class="title">imgUrlFormat</span><span class="params">(Map&lt;String, Object&gt; photo, <span class="keyword">int</span> width, <span class="keyword">int</span> height)</span> </span>&#123;</span><br><span class="line">        Map&lt;String, Object&gt; urls = (Map&lt;String, Object&gt;) photo.get(<span class="string">&quot;urls&quot;</span>);</span><br><span class="line">        String base = (String) (urls.getOrDefault(<span class="string">&quot;raw&quot;</span>,</span><br><span class="line">                urls.getOrDefault(<span class="string">&quot;full&quot;</span>, urls.get(<span class="string">&quot;regular&quot;</span>))));</span><br><span class="line">        <span class="keyword">if</span> (base == <span class="keyword">null</span> || base.isBlank()) &#123;</span><br><span class="line">            <span class="keyword">throw</span> <span class="keyword">new</span> IllegalStateException(<span class="string">&quot;No downloadable url in photo.urls&quot;</span>);</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        String join = base.contains(<span class="string">&quot;?&quot;</span>) ? <span class="string">&quot;&amp;&quot;</span> : <span class="string">&quot;?&quot;</span>;</span><br><span class="line">        <span class="comment">// q: compress img quality 1-100</span></span><br><span class="line">        <span class="keyword">return</span> base + join + <span class="string">&quot;w=&quot;</span> + width + <span class="string">&quot;&amp;h=&quot;</span> + height + <span class="string">&quot;&amp;fit=crop&amp;fm=jpg&amp;q=80&amp;auto=format&quot;</span>;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h2 id="其他"><a href="#其他" class="headerlink" title="其他"></a>其他</h2><h3 id="全局异常处理器"><a href="#全局异常处理器" class="headerlink" title="全局异常处理器"></a>全局异常处理器</h3><p><code>BusinessException</code> 和 <code>AuthExceptions</code> 为自定义异常</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.demo.api.exception;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.demo.api.ApiRespond;</span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.databind.JsonNode;</span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.databind.ObjectMapper;</span><br><span class="line"><span class="keyword">import</span> feign.FeignException;</span><br><span class="line"><span class="keyword">import</span> jakarta.servlet.http.HttpServletResponse;</span><br><span class="line"><span class="keyword">import</span> lombok.extern.slf4j.Slf4j;</span><br><span class="line"><span class="keyword">import</span> org.springframework.http.HttpStatus;</span><br><span class="line"><span class="keyword">import</span> org.springframework.security.access.AccessDeniedException;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.MethodArgumentNotValidException;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.ExceptionHandler;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.ResponseStatus;</span><br><span class="line"><span class="keyword">import</span> org.springframework.web.bind.annotation.RestControllerAdvice;</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> java.io.IOException;</span><br><span class="line"><span class="keyword">import</span> java.util.Optional;</span><br><span class="line"></span><br><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Global exception handler</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Slf4j</span></span><br><span class="line"><span class="meta">@RestControllerAdvice</span></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">GlobalExceptionHandler</span> </span>&#123;</span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Handle all uncaught exceptions</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@ExceptionHandler(Exception.class)</span></span><br><span class="line">    <span class="meta">@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> ApiRespond&lt;Void&gt; <span class="title">handleException</span><span class="params">(Exception e)</span> </span>&#123;</span><br><span class="line">        log.error(<span class="string">&quot;Unhandled exception&quot;</span>, e);</span><br><span class="line">        <span class="keyword">return</span> ApiRespond.error(<span class="string">&quot;System Wrong: &quot;</span> + e.getMessage());</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Handle business exceptions</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@ExceptionHandler(BusinessException.class)</span></span><br><span class="line">    <span class="meta">@ResponseStatus(HttpStatus.BAD_REQUEST)</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> ApiRespond&lt;Void&gt; <span class="title">handleBusinessException</span><span class="params">(BusinessException e)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">return</span> ApiRespond.error(e.getMessage());</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Handle parameter validation exceptions</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@ExceptionHandler(IllegalArgumentException.class)</span></span><br><span class="line">    <span class="meta">@ResponseStatus(HttpStatus.BAD_REQUEST)</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> ApiRespond&lt;Void&gt; <span class="title">handleIllegalArgumentException</span><span class="params">(IllegalArgumentException e)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">return</span> ApiRespond.error(<span class="string">&quot;Parameter Error: &quot;</span> + e.getMessage());</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Handle <span class="doctag">@Valid</span> validation failures</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@ExceptionHandler(MethodArgumentNotValidException.class)</span></span><br><span class="line">    <span class="meta">@ResponseStatus(HttpStatus.BAD_REQUEST)</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> ApiRespond&lt;Void&gt; <span class="title">handleMethodArgumentNotValid</span><span class="params">(MethodArgumentNotValidException e)</span> </span>&#123;</span><br><span class="line">        String msg = e.getBindingResult().getFieldErrors().stream()</span><br><span class="line">                .findFirst()</span><br><span class="line">                .map(err -&gt; err.getField() + <span class="string">&quot;: &quot;</span> + err.getDefaultMessage())</span><br><span class="line">                .orElse(<span class="string">&quot;Parameter Verification Failed&quot;</span>);</span><br><span class="line">        <span class="keyword">return</span> ApiRespond.error(msg);</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Handle authentication exceptions</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@ExceptionHandler(AuthException.class)</span></span><br><span class="line">    <span class="meta">@ResponseStatus(HttpStatus.UNAUTHORIZED)</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> ApiRespond&lt;Void&gt; <span class="title">handleAuthException</span><span class="params">(AuthException e)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">return</span> ApiRespond.error(e.getMessage());</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">/**</span></span><br><span class="line"><span class="comment">     * Handle authorization exceptions</span></span><br><span class="line"><span class="comment">     */</span></span><br><span class="line">    <span class="meta">@ExceptionHandler(AccessDeniedException.class)</span></span><br><span class="line">    <span class="meta">@ResponseStatus(HttpStatus.FORBIDDEN)</span></span><br><span class="line">    <span class="function"><span class="keyword">public</span> ApiRespond&lt;Void&gt; <span class="title">handleAccessDenied</span><span class="params">(AccessDeniedException e)</span> </span>&#123;</span><br><span class="line">        <span class="keyword">return</span> ApiRespond.error(<span class="string">&quot;No Access&quot;</span>);</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure><h3 id="统一响应结果"><a href="#统一响应结果" class="headerlink" title="统一响应结果"></a>统一响应结果</h3><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/**</span></span><br><span class="line"><span class="comment"> * Unified response format</span></span><br><span class="line"><span class="comment"> * <span class="doctag">@param</span> &lt;T&gt; data</span></span><br><span class="line"><span class="comment"> */</span></span><br><span class="line"><span class="meta">@Data</span></span><br><span class="line"><span class="meta">@NoArgsConstructor</span></span><br><span class="line"><span class="meta">@AllArgsConstructor</span></span><br><span class="line"><span class="keyword">public</span> <span class="class"><span class="keyword">class</span> <span class="title">ApiRespond</span>&lt;<span class="title">T</span>&gt; </span>&#123;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">int</span> code; <span class="comment">// Response code: 1 means success, 0 means failure</span></span><br><span class="line">    <span class="keyword">private</span> String msg; <span class="comment">// Message. Success: success; Failure: exception message</span></span><br><span class="line">    <span class="keyword">private</span> T data; <span class="comment">// Returned data</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> &lt;Z&gt; <span class="function">ApiRespond&lt;Z&gt; <span class="title">success</span><span class="params">()</span></span>&#123;</span><br><span class="line">        ApiRespond&lt;Z&gt; apiRespond = <span class="keyword">new</span> ApiRespond&lt;&gt;();</span><br><span class="line">        apiRespond.code = <span class="number">1</span>;</span><br><span class="line">        apiRespond.msg = <span class="string">&quot;success&quot;</span>;</span><br><span class="line">        <span class="keyword">return</span> apiRespond;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> &lt;Z&gt; <span class="function">ApiRespond&lt;Z&gt; <span class="title">success</span><span class="params">(Z data)</span></span>&#123;</span><br><span class="line">        ApiRespond&lt;Z&gt; apiRespond = <span class="keyword">new</span> ApiRespond&lt;&gt;();</span><br><span class="line">        apiRespond.code = <span class="number">1</span>;</span><br><span class="line">        apiRespond.msg = <span class="string">&quot;success&quot;</span>;</span><br><span class="line">        apiRespond.data = data;</span><br><span class="line">        <span class="keyword">return</span> apiRespond;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">static</span> &lt;Z&gt; <span class="function">ApiRespond&lt;Z&gt; <span class="title">error</span><span class="params">(String msg)</span></span>&#123;</span><br><span class="line">        ApiRespond&lt;Z&gt; apiRespond = <span class="keyword">new</span> ApiRespond&lt;&gt;();</span><br><span class="line">        apiRespond.code = <span class="number">0</span>;</span><br><span class="line">        apiRespond.msg = msg;</span><br><span class="line">        <span class="keyword">return</span> apiRespond;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br></pre></td></tr></table></figure></article><div class="post-copyright"><div class="post-copyright__title"><span class="post-copyright-info"><h>AI Trip Assistant 复盘</h></span></div><div class="post-copyright__type"><span class="post-copyright-info"><a href="https://dingzh.cc/posts/b95b9d3f.html">https://dingzh.cc/posts/b95b9d3f.html</a></span></div><div class="post-copyright-m"><div class="post-copyright-m-info"><div class="post-copyright-a"><h>作者</h><div class="post-copyright-cc-info"><h>Dingzh</h></div></div><div class="post-copyright-c"><h>发布于</h><div class="post-copyright-cc-info"><h>2025-10-27</h></div></div><div class="post-copyright-u"><h>更新于</h><div class="post-copyright-cc-info"><h>2025-11-11</h></div></div><div class="post-copyright-c"><h>许可协议</h><div class="post-copyright-cc-info"><a class="icon" rel="noopener" target="_blank" title="Creative Commons" href="https://creativecommons.org/"><i class="fab fa-creative-commons"></i></a><a rel="noopener" target="_blank" title="CC BY-NC-SA 4.0" href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a></div></div></div></div></div><div class="tag_share"><div class="post-meta__tag-list"><a class="post-meta__tags" href="/tags/Java/">Java</a><a class="post-meta__tags" href="/tags/AI/">AI</a></div><div class="post_share"><div class="social-share" data-image="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover186.jpg" data-sites="facebook,twitter,wechat,weibo,qq"></div><link rel="stylesheet" href="https://npm.elemecdn.com/social-share.js/dist/css/share.min.css" media="print" onload='this.media="all"'><script src="https://npm.elemecdn.com/social-share.js/dist/js/social-share.min.js" defer></script></div></div><div class="post-reward"><div class="reward-button button--animated"><i class="fas fa-qrcode"></i> 打赏<div class="reward-main"><ul class="reward-all"><li class="reward-item"><a href="https://alicdn.dingzh.cc/ding-BlogDemo/img/wechat.jpg" target="_blank"><img class="post-qr-code-img" src="https://alicdn.dingzh.cc/ding-BlogDemo/img/wechat.jpg" alt="微信"></a><div class="post-qr-code-desc">微信</div></li><li class="reward-item"><a href="https://alicdn.dingzh.cc/ding-BlogDemo/img/alipay.jpg" target="_blank"><img class="post-qr-code-img" src="https://alicdn.dingzh.cc/ding-BlogDemo/img/alipay.jpg" alt="支付宝"></a><div class="post-qr-code-desc">支付宝</div></li></ul></div></div></div><nav class="pagination-post" id="pagination"><div class="prev-post pull-left"><a href="/posts/28b32455.html"><img class="prev-cover" src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover333.jpg" onerror='onerror=null,src="https://alicdn.dingzh.cc/ding-BlogDemo/img/404_img.jpg"' alt="cover of previous post"><div class="pagination-info"><div class="label">上一篇</div><div class="prev_info">杀戮尖塔自用牌组记录</div></div></a></div><div class="next-post pull-right"><a href="/posts/d4e383e3.html"><img class="next-cover" src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover217.jpg" onerror='onerror=null,src="https://alicdn.dingzh.cc/ding-BlogDemo/img/404_img.jpg"' alt="cover of next post"><div class="pagination-info"><div class="label">下一篇</div><div class="next_info">对 AI Agent 的一些粗浅理解</div></div></a></div></nav><hr><div id="post-comment"><div class="comment-head"><div class="comment-headline"><i class="fas fa-comments fa-fw"></i> <span>评论</span></div></div><div class="comment-wrap"><div><div id="twikoo-wrap"></div></div></div></div></div><div class="aside-content" id="aside-content"><div class="card-widget card-info"><div class="is-center"><div class="avatar-img"><img src="https://alicdn.dingzh.cc/ding-BlogDemo/img/head1.jpg" onerror='this.onerror=null,this.src="https://alicdn.dingzh.cc/ding-BlogDemo/img/imgLink_404.gif"' alt="avatar"></div><div class="author-info__name">Dingzh</div><div class="author-info__description">为之则易，不为则难</div></div><div class="card-info-data"><div class="card-info-data-item is-center"><a href="/archives/"><div class="headline">文章</div><div class="length-num">38</div></a></div><div class="card-info-data-item is-center"><a href="/tags/"><div class="headline">标签</div><div class="length-num">49</div></a></div><div class="card-info-data-item is-center"><a href="/categories/"><div class="headline">分类</div><div class="length-num">9</div></a></div></div><a class="button--animated" id="card-info-btn" href="javascript:void(0)" onclick="addFav()"><i class="fas fa-star"></i><span>收藏本站</span></a><div class="card-info-social-icons is-center"><a class="social-icon" href="https://github.com/rplalala" target="_blank" title="Github"><i class="fab iconfont icon-github"></i></a><a class="social-icon" href="https://mail.qq.com/cgi-bin/qm_share?t=qm_mailme&amp;email=dingzihang00@qq.com" target="_blank" title="Email"><i class="fas fa-envelope"></i></a><a class="social-icon" href="https://gitee.com/rplalala" target="_blank" title="Gitee"><i class="fab iconfont icon-gitee-fill-round"></i></a><a class="social-icon" href="https://blog.csdn.net/RP123123123?spm=1000.2115.3001.5343" target="_blank" title="CSDN"><i class="fab iconfont icon-csdn1-copy"></i></a><a class="social-icon" href="https://leetcode-cn.com/u/dingzihang00/" target="_blank" title="LeetCode"><i class="fab iconfont icon-leetcode"></i></a><a class="social-icon" href="https://space.bilibili.com/6964209" target="_blank" title="Bilibili"><i class="fab iconfont icon-bilibili-fill"></i></a><a class="social-icon" href="https://i.y.qq.com/n2/m/share/details/taoge.html?id=3885764921" target="_blank" title="QQ音乐"><i class="fab iconfont icon-QQyinle"></i></a></div></div><div class="sticky_layout"><div class="card-widget" id="card-toc"><div class="item-headline"><i class="fas fa-stream"></i><span>目录</span></div><div class="toc-content"><ol class="toc"><li class="toc-item toc-level-2"><a class="toc-link" href="#%E6%80%BB%E7%BB%93"><span class="toc-number">1.</span> <span class="toc-text">总结</span></a></li><li class="toc-item toc-level-2"><a class="toc-link" href="#%E9%89%B4%E6%9D%83"><span class="toc-number">2.</span> <span class="toc-text">鉴权</span></a><ol class="toc-child"><li class="toc-item toc-level-3"><a class="toc-link" href="#JwtConfig"><span class="toc-number">2.1.</span> <span class="toc-text">JwtConfig</span></a></li><li class="toc-item toc-level-3"><a class="toc-link" href="#JwtFilter"><span class="toc-number">2.2.</span> <span class="toc-text">JwtFilter</span></a></li><li class="toc-item toc-level-3"><a class="toc-link" href="#JwtUtils"><span class="toc-number">2.3.</span> <span class="toc-text">JwtUtils</span></a></li></ol></li><li class="toc-item toc-level-2"><a class="toc-link" href="#%E5%B7%A5%E5%85%B7%E7%B1%BB"><span class="toc-number">3.</span> <span class="toc-text">工具类</span></a><ol class="toc-child"><li class="toc-item toc-level-3"><a class="toc-link" href="#AWS-S3-Utils"><span class="toc-number">3.1.</span> <span class="toc-text">AWS S3 Utils</span></a></li><li class="toc-item toc-level-3"><a class="toc-link" href="#JWT-Utils"><span class="toc-number">3.2.</span> <span class="toc-text">JWT Utils</span></a></li><li class="toc-item toc-level-3"><a class="toc-link" href="#SendGrid-Email-API-Utils"><span class="toc-number">3.3.</span> <span class="toc-text">SendGrid Email API Utils</span></a></li><li class="toc-item toc-level-3"><a class="toc-link" href="#Unsplash-Image-Utils"><span class="toc-number">3.4.</span> <span class="toc-text">Unsplash Image Utils</span></a></li></ol></li><li class="toc-item toc-level-2"><a class="toc-link" href="#%E5%85%B6%E4%BB%96"><span class="toc-number">4.</span> <span class="toc-text">其他</span></a><ol class="toc-child"><li class="toc-item toc-level-3"><a class="toc-link" href="#%E5%85%A8%E5%B1%80%E5%BC%82%E5%B8%B8%E5%A4%84%E7%90%86%E5%99%A8"><span class="toc-number">4.1.</span> <span class="toc-text">全局异常处理器</span></a></li><li class="toc-item toc-level-3"><a class="toc-link" href="#%E7%BB%9F%E4%B8%80%E5%93%8D%E5%BA%94%E7%BB%93%E6%9E%9C"><span class="toc-number">4.2.</span> <span class="toc-text">统一响应结果</span></a></li></ol></li></ol></div></div><div class="card-widget card-recommend-post"><div class="item-headline"><i class="fas fa-thumbs-up"></i><span>相关推荐</span></div><div class="aside-list"><div class="aside-list-item"><a class="thumbnail" href="/posts/1e5fbfe0.html" title="用Swing和MySQL做一个学生管理系统"><img src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover57.jpg" alt="用Swing和MySQL做一个学生管理系统"></a><div class="content"><a class="title" href="/posts/1e5fbfe0.html" title="用Swing和MySQL做一个学生管理系统">用Swing和MySQL做一个学生管理系统</a><time datetime="2022-02-17" title="发表于 2022-02-17">2022-02-17</time></div></div><div class="aside-list-item"><a class="thumbnail" href="/posts/14f08eda.html" title="RAG / MCP / Prompt 和一些常用的 Agent 框架"><img src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover155.jpg" alt="RAG / MCP / Prompt 和一些常用的 Agent 框架"></a><div class="content"><a class="title" href="/posts/14f08eda.html" title="RAG / MCP / Prompt 和一些常用的 Agent 框架">RAG / MCP / Prompt 和一些常用的 Agent 框架</a><time datetime="2025-11-11" title="发表于 2025-11-11">2025-11-11</time></div></div><div class="aside-list-item"><a class="thumbnail" href="/posts/d4e383e3.html" title="对 AI Agent 的一些粗浅理解"><img src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover217.jpg" alt="对 AI Agent 的一些粗浅理解"></a><div class="content"><a class="title" href="/posts/d4e383e3.html" title="对 AI Agent 的一些粗浅理解">对 AI Agent 的一些粗浅理解</a><time datetime="2025-11-09" title="发表于 2025-11-09">2025-11-09</time></div></div></div></div><div class="card-widget card-recent-post"><div class="item-headline"><i class="fas fa-history"></i><span>最新文章</span></div><div class="aside-list"><div class="aside-list-item"><a class="thumbnail" href="/posts/b6ddf346.html" title="leetcode hot 100 刷题笔记"><img src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover258.jpg" onerror='this.onerror=null,this.src="https://alicdn.dingzh.cc/ding-BlogDemo/img/404_img.jpg"' alt="leetcode hot 100 刷题笔记"></a><div class="content"><a class="title" href="/posts/b6ddf346.html" title="leetcode hot 100 刷题笔记">leetcode hot 100 刷题笔记</a><time datetime="2025-11-17T10:40:06.000Z" title="发表于 2025-11-17 18:40:06">2025-11-17</time></div></div><div class="aside-list-item"><a class="thumbnail" href="/posts/5b98c3b1.html" title="纯JWT TOKEN 和Redis + JWT TOKEN的区别和应用场景"><img src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover122.jpg" onerror='this.onerror=null,this.src="https://alicdn.dingzh.cc/ding-BlogDemo/img/404_img.jpg"' alt="纯JWT TOKEN 和Redis + JWT TOKEN的区别和应用场景"></a><div class="content"><a class="title" href="/posts/5b98c3b1.html" title="纯JWT TOKEN 和Redis + JWT TOKEN的区别和应用场景">纯JWT TOKEN 和Redis + JWT TOKEN的区别和应用场景</a><time datetime="2025-11-16T16:24:56.000Z" title="发表于 2025-11-17 00:24:56">2025-11-17</time></div></div><div class="aside-list-item"><a class="thumbnail" href="/posts/14f08eda.html" title="RAG / MCP / Prompt 和一些常用的 Agent 框架"><img src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover155.jpg" onerror='this.onerror=null,this.src="https://alicdn.dingzh.cc/ding-BlogDemo/img/404_img.jpg"' alt="RAG / MCP / Prompt 和一些常用的 Agent 框架"></a><div class="content"><a class="title" href="/posts/14f08eda.html" title="RAG / MCP / Prompt 和一些常用的 Agent 框架">RAG / MCP / Prompt 和一些常用的 Agent 框架</a><time datetime="2025-11-11T12:46:47.000Z" title="发表于 2025-11-11 20:46:47">2025-11-11</time></div></div><div class="aside-list-item"><a class="thumbnail" href="/posts/7a10715.html" title="什么是 MCP，什么是 MCP Server"><img src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover16.jpg" onerror='this.onerror=null,this.src="https://alicdn.dingzh.cc/ding-BlogDemo/img/404_img.jpg"' alt="什么是 MCP，什么是 MCP Server"></a><div class="content"><a class="title" href="/posts/7a10715.html" title="什么是 MCP，什么是 MCP Server">什么是 MCP，什么是 MCP Server</a><time datetime="2025-11-11T08:05:27.000Z" title="发表于 2025-11-11 16:05:27">2025-11-11</time></div></div><div class="aside-list-item"><a class="thumbnail" href="/posts/1383d4dc.html" title="阿里云CDN 配置国内外双源站策略，加快国内外访问"><img src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover39.jpg" onerror='this.onerror=null,this.src="https://alicdn.dingzh.cc/ding-BlogDemo/img/404_img.jpg"' alt="阿里云CDN 配置国内外双源站策略，加快国内外访问"></a><div class="content"><a class="title" href="/posts/1383d4dc.html" title="阿里云CDN 配置国内外双源站策略，加快国内外访问">阿里云CDN 配置国内外双源站策略，加快国内外访问</a><time datetime="2025-11-09T17:28:32.000Z" title="发表于 2025-11-10 01:28:32">2025-11-10</time></div></div></div></div></div></div></main><footer id="footer"><div id="footer-wrap"><div class="copyright"><span>&copy;2021 - 2025</span><svg style="width:1.5em;height:1.5em" aria-hidden="true"><use xlink:href="#icon-diqiu"></use></svg><span>Dingzh</span></div><div class="footer_custom_text"><span id="timeShow"></span></div><p id="ghbdages"><a class="github-badge" target="_blank" href="https://github.com/rplalala/rplalala.github.io" style="margin-inline:5px"><img src="https://img.shields.io/badge/Source-Github-d021d6?style=flat&amp;logo=GitHub" title="本站项目由Github托管"></a><a class="github-badge" target="_blank" href="https://gitee.com/rplalala/rplalala" style="margin-inline:5px"><img src="https://img.shields.io/badge/Mirror-Gitee-0cedbe?style=flat&amp;logo=Gitee" title="本站国内镜像由Gitee托管"></a><a class="github-badge" target="_blank" href="https://vercel.com/" style="margin-inline:5px"><img src="https://img.shields.io/badge/Hosted-Vercel-brightgreen?style=flat&amp;logo=Vercel" title="本站采用双线部署，默认线路托管于Vercel"></a><a class="github-badge" target="_blank" href="https://www.aliyun.com/" style="margin-inline:5px"><img src="https://img.shields.io/badge/CDN-Aliyun-orange?style=flat&amp;logo=AlibabaCloud" title="本站使用阿里云提供CDN加速"></a><a class="github-badge" target="_blank" href="https://beian.miit.gov.cn/" style="margin-inline:5px"><img src="https://img.shields.io/badge/%E9%97%BDICP%E5%A4%87-2022001031%E5%8F%B7-e1d492?style=flat&amp;logo=" title="本站已在工信部备案，备案号闽ICP备-2022001031号"></a><a class="github-badge" target="_blank" href="https://icp.gov.moe/?keyword=20221220" style="margin-inline:5px"><img src="https://img.shields.io/badge/%E8%90%8CICP%E5%A4%87-20221220%E5%8F%B7-fe1384?style-flat&amp;logo=" title="本站已加入萌ICP备，萌号萌ICP备-20221220号"></a><a class="github-badge" target="_blank" href="https://creativecommons.org/licenses/by-nc-sa/4.0/" style="margin-inline:5px"><img src="https://img.shields.io/badge/Copyright-BY--NC--SA%204.0-d42328?style=flat&amp;logo=Claris" title="本站采用知识共享署名-非商业性使用-相同方式共享4.0国际许可协议进行许可"></a><a class="github-badge" target="_blank" href="https://butterfly.js.org/" style="margin-inline:5px"><img src="https://img.shields.io/badge/Theme-Butterfly-6513df?style=flat&amp;logo=bitdefender" title="主题版本为Butterfly_v3.8.4"></a><a class="github-badge" target="_blank" href="https://hexo.io/" style="margin-inline:5px"><img src="https://img.shields.io/badge/Frame-Hexo-blue?style=flat&amp;logo=hexo" title="博客框架为Hexo_v6.0.0"></a></p></div></footer></div><div id="rightside"><div id="rightside-config-hide"><button id="font-plus" type="button" title="放大字体"><i class="fas fa-plus"></i></button><button id="font-minus" type="button" title="缩小字体"><i class="fas fa-minus"></i></button></div><div id="rightside-config-show"><button id="rightside_config" type="button" title="设置"><i class="fas fa-cog fa-spin"></i></button><button class="close" id="mobile-toc-button" type="button" title="目录"><i class="fas fa-list-ul"></i></button><button id="readmode" type="button" title="阅读模式"><i class="fas fa-book-open"></i></button><a class="icon-V hidden" onclick="switchNightMode()" title="浅色和深色模式转换"><svg width="25" height="25" viewBox="0 0 1024 1024"><use id="modeicon" xlink:href="#icon-moon"></use></svg></a><button id="change-bg-btn" type="button" title="更换背景" onclick="(function(b){var i=b.querySelector('i');if(i)i.classList.add('fa-spin');b.disabled=true;Promise.resolve(window.changeBackground &amp;&amp; changeBackground()).catch(function(e){try{console.error(e)}catch(e){}}).then(function(){if(i)i.classList.remove('fa-spin');b.disabled=false;});})(this)"><i class="fas fa-image"></i></button><button id="cycle-bg-mode-btn" type="button" title="背景模式" data-title-base="背景模式" data-random-label="随机背景" data-white-label="纯白背景" data-black-label="纯黑背景" onclick="window.cycleBackgroundMode &amp;&amp; cycleBackgroundMode(this)"><i class="fas fa-palette"></i></button><button id="hide-aside-btn" type="button" title="单栏和双栏切换"><i class="fas fa-arrows-alt-h"></i></button><button id="to_comment" type="button" title="直达评论" onclick="FixedCommentBtn()"><i class="fas fa-comments"></i></button><button id="go-up" type="button" title="回到顶部"><i class="fas fa-arrow-up"></i></button></div></div><div id="local-search"><div class="search-dialog"><div class="search-dialog__title" id="local-search-title">本地搜索</div><div id="local-input-panel"><div id="local-search-input"><div class="local-search-box"><input class="local-search-box--input" placeholder="搜索文章" type="text"></div></div></div><hr><div id="local-search-results"></div><span class="search-close-button"><i class="fas fa-times"></i></span></div><div id="search-mask"></div></div><div><script src="https://npm.elemecdn.com/jquery@latest/dist/jquery.min.js"></script><script src="https://alicdn.dingzh.cc/ding-BlogDemo/js/utils.min.js"></script><script src="https://alicdn.dingzh.cc/ding-BlogDemo/js/main.min.js"></script><script defer src="https://npm.elemecdn.com/instant.page/instantpage.js" type="module"></script><script src="https://npm.elemecdn.com/vanilla-lazyload/dist/lazyload.iife.min.js"></script><script defer src="https://npm.elemecdn.com/node-snackbar/dist/snackbar.min.js"></script><script>function panguFn(){"object"==typeof pangu?pangu.autoSpacingPage():getScript("https://alicdn.dingzh.cc/ding-BlogDemo/js/other/pangu.min.js").then(()=>{pangu.autoSpacingPage()})}function panguInit(){panguFn()}document.addEventListener("DOMContentLoaded",panguInit)</script><script defer src="https://alicdn.dingzh.cc/ding-BlogDemo/js/search/local-search.min.js"></script><script async>var preloader={endLoading:()=>{document.body.style.overflow="auto",document.getElementById("loading-box").classList.add("loaded")},initLoading:()=>{document.body.style.overflow="",document.getElementById("loading-box").classList.remove("loaded")}};window.addEventListener("load",()=>{preloader.endLoading()}),setTimeout((function(){preloader.endLoading()}),2500)</script><div class="js-pjax"><script>(()=>{const t=document.getElementById("twikoo-count"),o=()=>{twikoo.init(Object.assign({el:"#twikoo-wrap",envId:"https://twikoo.dingzh.cc/",region:""},null))},n=()=>{twikoo.getCommentsCount({envId:"https://twikoo.dingzh.cc/",region:"",urls:[window.location.pathname],includeReply:!1}).then((function(o){t.innerText=o[0].count})).catch((function(t){console.error(t)}))},e=(e=!1)=>{"object"==typeof twikoo?(o(),e&&t&&setTimeout(n,0)):getScript("https://alicdn.dingzh.cc/ding-BlogDemo/js/other/twikoo.all.min.js").then(()=>{o(),e&&t&&setTimeout(n,0)})};btf.loadComment(document.getElementById("twikoo-wrap"),e)})()</script></div><script>window.addEventListener("load",()=>{const e=e=>{let t="";if(e.length)for(let n=0;n<e.length;n++){t+="<div class='aside-list-item'>";{const a="data-lazy-src";t+=`<a href='${e[n].url}' class='thumbnail'><img ${a}='${e[n].avatar}' alt='${e[n].nick}'></a>`}t+=`<div class='content'>\n        <a class='comment' href='${e[n].url}'>${e[n].content}</a>\n        <div class='name'><span>${e[n].nick} / </span><time datetime="${e[n].date}">${btf.diffDate(e[n].date,!0)}</time></div>\n        </div></div>`}else t+="没有评论";let n=document.querySelector("#card-newest-comments .aside-list");n.innerHTML=t,window.lazyLoadInstance&&window.lazyLoadInstance.update(),window.pjax&&window.pjax.refresh(n)},t=()=>{if(document.querySelector("#card-newest-comments .aside-list")){const t=saveToLocal.get("twikoo-newest-comments");t?e(JSON.parse(t)):(()=>{const t=()=>{twikoo.getRecentComments({envId:"https://twikoo.dingzh.cc/",region:"",pageSize:6,includeReply:!0}).then((function(t){const n=t.map(e=>{return{content:(t=e.comment,""===t||(t=(t=(t=(t=t.replace(/<img.*?src="(.*?)"?[^\>]+>/gi,"[图片]")).replace(/<a[^>]+?href=["']?([^"']+)["']?[^>]*>([^<]+)<\/a>/gi,"[链接]")).replace(/<pre><code>.*?<\/pre>/gi,"[代码]")).replace(/<[^>]+>/g,"")).length>150&&(t=t.substring(0,150)+"..."),t),avatar:e.avatar,nick:e.nick,url:e.url+"#"+e.id,date:new Date(e.created).toISOString()};var t});saveToLocal.set("twikoo-newest-comments",JSON.stringify(n),10/1440),e(n)})).catch((function(e){document.querySelector("#card-newest-comments .aside-list").innerHTML="无法获取评论，请确认相关配置是否正确"}))};"object"==typeof twikoo?t():getScript("https://alicdn.dingzh.cc/ding-BlogDemo/js/other/twikoo.all.min.js").then(t)})()}};t(),document.addEventListener("pjax:complete",t)})</script><script async src="https://alicdn.dingzh.cc/ding-BlogDemo/js/custom/myJS.min.js"></script><script async src="https://at.alicdn.com/t/font_3113985_v9z1ah1pbeo.js"></script><script async src="https://at.alicdn.com/t/font_2032782_ev6ytrh30f.js"></script><script defer src="https://alicdn.dingzh.cc/ding-BlogDemo/js/custom/baidu_sub.min.js"></script><script data-pjax defer src="https://alicdn.dingzh.cc/ding-BlogDemo/js/custom/fixed_card_widget.min.js"></script><script data-pjax defer src="https://alicdn.dingzh.cc/ding-BlogDemo/js/custom/fixedComment_twikoo.min.js"></script><script src="https://alicdn.dingzh.cc/ding-BlogDemo/js/custom/sun_moon.min.js" async></script><script src="https://npm.elemecdn.com/butterfly-extsrc@1/dist/activate-power-mode.min.js"></script><script>POWERMODE.colorful=!0,POWERMODE.shake=!1,POWERMODE.mobile=!1,document.body.addEventListener("input",POWERMODE)</script><script id="click-heart" src="https://npm.elemecdn.com/butterfly-extsrc@1/dist/click-heart.min.js" async mobile="false"></script><script src="https://npm.elemecdn.com/pjax/pjax.min.js"></script><script>let pjaxSelectors=["title","#config-diff","#body-wrap","#rightside-config-hide","#rightside-config-show","#fixedcard-dashboard",".js-pjax"];var pjax=new Pjax({elements:'a:not([target="_blank"])',selectors:pjaxSelectors,cacheBust:!1,analytics:!1,scrollRestoration:!1});document.addEventListener("pjax:send",(function(){if(window.removeEventListener("scroll",window.tocScrollFn),window.removeEventListener("scroll",scrollCollect),"object"==typeof preloader&&preloader.initLoading(),window.aplayers)for(let e=0;e<window.aplayers.length;e++)window.aplayers[e].options.fixed||window.aplayers[e].destroy();"object"==typeof typed&&typed.destroy();const e=document.body.classList;e.contains("read-mode")&&e.remove("read-mode")})),document.addEventListener("pjax:complete",(function(){window.refreshFn(),document.querySelectorAll("script[data-pjax]").forEach(e=>{const t=document.createElement("script"),o=e.text||e.textContent||e.innerHTML||"";Array.from(e.attributes).forEach(e=>t.setAttribute(e.name,e.value)),t.appendChild(document.createTextNode(o)),e.parentNode.replaceChild(t,e)}),GLOBAL_CONFIG.islazyload&&window.lazyLoadInstance.update(),"function"==typeof chatBtnFn&&chatBtnFn(),"function"==typeof panguInit&&panguInit(),"function"==typeof gtag&&gtag("config","",{page_path:window.location.pathname}),"object"==typeof _hmt&&_hmt.push(["_trackPageview",window.location.pathname]),"object"==typeof Prism&&Prism.highlightAll(),"object"==typeof preloader&&preloader.endLoading()})),document.addEventListener("pjax:error",e=>{404===e.request.status&&pjax.loadUrl("/404.html")})</script><script async data-pjax src="https://busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js"></script><div id="fixedcard-dashboard"><button class="fixedcard-activebtn" type="button" title="用户信息" onclick="FixedCardWidget(&quot;class&quot;,&quot;card-info&quot;,&quot;0&quot;)"><i class="fas fa-address-book"></i></button><button class="fixedcard-activebtn" type="button" title="电子钟" onclick="FixedCardWidget(&quot;class&quot;,&quot;card-clock&quot;,&quot;0&quot;)"><i class="fas fa-cloud-sun"></i></button><button class="fixedcard-activebtn" type="button" title="最新文章" onclick="FixedCardWidget(&quot;class&quot;,&quot;card-recent-post&quot;,&quot;0&quot;)"><i class="fas fa-history"></i></button><button class="fixedcard-activebtn" type="button" title="相关推荐" onclick="FixedCardWidget(&quot;class&quot;,&quot;card-recommend-post&quot;,&quot;0&quot;)"><i class="fas fa-thumbs-up"></i></button><div class="fixedcard-user-avatar fixedcard-activebtn" onclick="RemoveFixedCardWidget()"><img class="fixedcard-user-avatar-img" src="https://alicdn.dingzh.cc/ding-BlogDemo/img/head1.jpg" title="Dingzh"></div></div><div class="app-refresh" id="app-refresh" style="position:fixed;top:-2.2rem;left:0;right:0;z-index:99999;padding:0 1rem;font-size:15px;height:2.2rem;transition:all .3s ease"><div class="app-refresh-wrap" style="display:flex;color:#fff;height:100%;align-items:center;justify-content:center"><label>🌈本站有新文章啦💌</label><a href="javascript:void(0)" onclick='top.location.href="/"'><span style="color:#fff;text-decoration:underline;cursor:pointer">🔎查看🤗</span></a></div></div><script>function showNotification(){if(GLOBAL_CONFIG.Snackbar){var t="light"===document.documentElement.getAttribute("data-theme")?GLOBAL_CONFIG.Snackbar.bgLight:GLOBAL_CONFIG.Snackbar.bgDark,e=GLOBAL_CONFIG.Snackbar.position;Snackbar.show({text:"🌈本站有新文章啦💌",backgroundColor:t,duration:5e5,pos:e,actionText:"🔎查看🤗",actionTextColor:"#fff",onActionClick:function(t){top.location.href="/"}})}else{var o=`top: 0; background: ${"light"===document.documentElement.getAttribute("data-theme")?"#49b1f5":"#1f1f1f"};`;document.getElementById("app-refresh").style.cssText=o}}"serviceWorker"in navigator&&(navigator.serviceWorker.controller&&navigator.serviceWorker.addEventListener("controllerchange",(function(){showNotification()})),window.addEventListener("load",(function(){navigator.serviceWorker.register("/sw.js")})))</script></div><script data-pjax>function card_artitalk_injector_config(){var a=document.getElementsByClassName("sticky_layout")[0];console.log("已挂载card_artitalk"),a.insertAdjacentHTML("afterbegin",'<div class="card-widget card-shuo"><div class="card-content" style="height:auto;min-height:280px;"><div class="item-headline"><i class="fas fa-comments"></i><span><a href="/artitalk/" title="artitalk page link">随言</a></span><a id="cardVisual" style="cursor:pointer;float:right" onclick="cardVisual()">编辑</a></div><div id="artitalk_main" style="width:100%;height:100%;padding:1px"></div></div></div>'),(()=>{const a=()=>{new Artitalk(Object.assign({appId:"5EG25co78od7O4GsnIcIySD9-MdYXbMMI",appKey:"e8zuU9tM3oRrN9kp9PWQwmYW"},{serverURL:"https://talkapi.dingzh.cc",lang:"zh",pageSize:1,color1:"#ff6262",color2:"#00a9c4",atEmoji:null}))};"function"==typeof Artitalk?a():getScript("https://npm.elemecdn.com/artitalk").then(a)})()}for(var elist="/artitalk/,/posts/".split(","),cpage=location.pathname,epage="/",flag=0,i=0;i<elist.length;i++)cpage.includes(elist[i])&&flag++;("all"===epage&&0==flag||epage===cpage)&&card_artitalk_injector_config()</script><script async src="https://alicdn.dingzh.cc/ding-BlogDemo/js/other/card_visual.min.js"></script><script data-pjax>function butterfly_clock_anzhiyu_injector_config(){var a=document.getElementsByClassName("sticky_layout")[0];console.log("已挂载butterfly_clock_anzhiyu"),a&&a.insertAdjacentHTML("afterbegin",'<div class="card-widget card-clock"><div class="card-glass"><div class="card-background"><div class="card-content"><div id="hexo_electric_clock"><img class="entered loading" id="card-clock-loading" src="https://alicdn.dingzh.cc/ding-BlogDemo/img/loading.gif" style="height: 120px; width: 100%;" data-ll-status="loading"/></div></div></div></div></div>')}for(var elist="/archives/,/tags/,/categories/,/anime/,/gallery/,/artitalk/,/messageboard/,/link/,/about/".split(","),cpage=location.pathname,epage="all",qweather_key="dec7b69d45ab4dc4b9c3a6e8a13eb498",gaud_map_key="92c61ec107a5fc3b3abccc79ee948148",baidu_ak_key="undefined",flag=0,clock_rectangle="119.306239,26.075302",clock_default_rectangle_enable="false",i=0;i<elist.length;i++)cpage.includes(elist[i])&&flag++;("all"===epage&&0==flag||epage===cpage)&&butterfly_clock_anzhiyu_injector_config()</script><script src="https://pv.sohu.com/cityjson?ie=utf-8"></script><script data-pjax src="https://alicdn.dingzh.cc/ding-BlogDemo/clock/clock.min.js"></script><script data-pjax>function butterfly_swiper_injector_config(){var i=document.getElementsByClassName("recent-posts")[0];console.log("已挂载butterfly_swiper"),i.insertAdjacentHTML("afterbegin",'<div class="recent-post-item" style="height: auto;width: 100%"><div class="blog-slider swiper-container-fade swiper-container-horizontal" id="swiper_container"><div class="blog-slider__wrp swiper-wrapper" style="transition-duration: 0ms;"><div class="blog-slider__item swiper-slide" style="width: 750px; opacity: 1; transform: translate3d(0px, 0px, 0px); transition-duration: 0ms;"><a class="blog-slider__img" onclick="pjax.loadUrl(&quot;posts/d4e383e3.html&quot;);" href="javascript:void(0);" alt=""><img width="48" height="48" src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover217.jpg" alt="" onerror="this.src=https://unpkg.zhimg.com/akilar-candyassets/image/loading.gif; this.onerror = null;"/></a><div class="blog-slider__content"><span class="blog-slider__code">2025-11-09</span><a class="blog-slider__title" onclick="pjax.loadUrl(&quot;posts/d4e383e3.html&quot;);" href="javascript:void(0);" alt="">对 AI Agent 的一些粗浅理解</a><div class="blog-slider__text">总结我目前 AI Agent 的一些粗浅理解</div><a class="blog-slider__button" onclick="pjax.loadUrl(&quot;posts/d4e383e3.html&quot;);" href="javascript:void(0);" alt="">详情       </a></div></div><div class="blog-slider__item swiper-slide" style="width: 750px; opacity: 1; transform: translate3d(0px, 0px, 0px); transition-duration: 0ms;"><a class="blog-slider__img" onclick="pjax.loadUrl(&quot;posts/b95b9d3f.html&quot;);" href="javascript:void(0);" alt=""><img width="48" height="48" src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover186.jpg" alt="" onerror="this.src=https://unpkg.zhimg.com/akilar-candyassets/image/loading.gif; this.onerror = null;"/></a><div class="blog-slider__content"><span class="blog-slider__code">2025-10-28</span><a class="blog-slider__title" onclick="pjax.loadUrl(&quot;posts/b95b9d3f.html&quot;);" href="javascript:void(0);" alt="">AI Trip Assistant 复盘</a><div class="blog-slider__text">USYD 5620 小组项目AI Trip Assistant 复盘</div><a class="blog-slider__button" onclick="pjax.loadUrl(&quot;posts/b95b9d3f.html&quot;);" href="javascript:void(0);" alt="">详情       </a></div></div><div class="blog-slider__item swiper-slide" style="width: 750px; opacity: 1; transform: translate3d(0px, 0px, 0px); transition-duration: 0ms;"><a class="blog-slider__img" onclick="pjax.loadUrl(&quot;posts/7a10715.html&quot;);" href="javascript:void(0);" alt=""><img width="48" height="48" src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover16.jpg" alt="" onerror="this.src=https://unpkg.zhimg.com/akilar-candyassets/image/loading.gif; this.onerror = null;"/></a><div class="blog-slider__content"><span class="blog-slider__code">2025-11-11</span><a class="blog-slider__title" onclick="pjax.loadUrl(&quot;posts/7a10715.html&quot;);" href="javascript:void(0);" alt="">什么是 MCP，什么是 MCP Server</a><div class="blog-slider__text">理解 MCP 协议及 MCP Server 的作用</div><a class="blog-slider__button" onclick="pjax.loadUrl(&quot;posts/7a10715.html&quot;);" href="javascript:void(0);" alt="">详情       </a></div></div><div class="blog-slider__item swiper-slide" style="width: 750px; opacity: 1; transform: translate3d(0px, 0px, 0px); transition-duration: 0ms;"><a class="blog-slider__img" onclick="pjax.loadUrl(&quot;posts/14f08eda.html&quot;);" href="javascript:void(0);" alt=""><img width="48" height="48" src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover155.jpg" alt="" onerror="this.src=https://unpkg.zhimg.com/akilar-candyassets/image/loading.gif; this.onerror = null;"/></a><div class="blog-slider__content"><span class="blog-slider__code">2025-11-11</span><a class="blog-slider__title" onclick="pjax.loadUrl(&quot;posts/14f08eda.html&quot;);" href="javascript:void(0);" alt="">RAG / MCP / Prompt 和一些常用的 Agent 框架</a><div class="blog-slider__text">理解 RAG、MCP 和 Prompt 在 Agent 中的角色与区别，以及常用的 Agent 框架</div><a class="blog-slider__button" onclick="pjax.loadUrl(&quot;posts/14f08eda.html&quot;);" href="javascript:void(0);" alt="">详情       </a></div></div><div class="blog-slider__item swiper-slide" style="width: 750px; opacity: 1; transform: translate3d(0px, 0px, 0px); transition-duration: 0ms;"><a class="blog-slider__img" onclick="pjax.loadUrl(&quot;posts/8da89045.html&quot;);" href="javascript:void(0);" alt=""><img width="48" height="48" src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover239.jpg" alt="" onerror="this.src=https://unpkg.zhimg.com/akilar-candyassets/image/loading.gif; this.onerror = null;"/></a><div class="blog-slider__content"><span class="blog-slider__code">2022-01-06</span><a class="blog-slider__title" onclick="pjax.loadUrl(&quot;posts/8da89045.html&quot;);" href="javascript:void(0);" alt="">seo优化超详细步骤 — hexo_butterfly</a><div class="blog-slider__text">搜索引擎收录博客详细步骤，并讲解如何对博客进行SEO优化</div><a class="blog-slider__button" onclick="pjax.loadUrl(&quot;posts/8da89045.html&quot;);" href="javascript:void(0);" alt="">详情       </a></div></div><div class="blog-slider__item swiper-slide" style="width: 750px; opacity: 1; transform: translate3d(0px, 0px, 0px); transition-duration: 0ms;"><a class="blog-slider__img" onclick="pjax.loadUrl(&quot;posts/b3f89ff3.html&quot;);" href="javascript:void(0);" alt=""><img width="48" height="48" src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover117.jpg" alt="" onerror="this.src=https://unpkg.zhimg.com/akilar-candyassets/image/loading.gif; this.onerror = null;"/></a><div class="blog-slider__content"><span class="blog-slider__code">2022-01-05</span><a class="blog-slider__title" onclick="pjax.loadUrl(&quot;posts/b3f89ff3.html&quot;);" href="javascript:void(0);" alt="">JS、CSS异步加载优化方案</a><div class="blog-slider__text">拜读Akilar店长的文章后，自己对JS、CSS异步加载的总结与体会</div><a class="blog-slider__button" onclick="pjax.loadUrl(&quot;posts/b3f89ff3.html&quot;);" href="javascript:void(0);" alt="">详情       </a></div></div><div class="blog-slider__item swiper-slide" style="width: 750px; opacity: 1; transform: translate3d(0px, 0px, 0px); transition-duration: 0ms;"><a class="blog-slider__img" onclick="pjax.loadUrl(&quot;posts/b4caf892.html&quot;);" href="javascript:void(0);" alt=""><img width="48" height="48" src="https://alicdn.dingzh.cc/ding-BlogDemo/img/cover/cover131.jpg" alt="" onerror="this.src=https://unpkg.zhimg.com/akilar-candyassets/image/loading.gif; this.onerror = null;"/></a><div class="blog-slider__content"><span class="blog-slider__code">2022-01-07</span><a class="blog-slider__title" onclick="pjax.loadUrl(&quot;posts/b4caf892.html&quot;);" href="javascript:void(0);" alt="">部署博客中各种踩坑点记录</a><div class="blog-slider__text">部署博客也有半个月了，中间遇到了许多坑点，这里总结一下常规操作吧（其实也没多少，懒得写- -）。持续更新~</div><a class="blog-slider__button" onclick="pjax.loadUrl(&quot;posts/b4caf892.html&quot;);" href="javascript:void(0);" alt="">详情       </a></div></div></div><div class="blog-slider__pagination swiper-pagination-clickable swiper-pagination-bullets"></div></div></div>')}for(var elist="undefined".split(","),cpage=location.pathname,epage="/",flag=0,i=0;i<elist.length;i++)cpage.includes(elist[i])&&flag++;("all"===epage&&0==flag||epage===cpage)&&butterfly_swiper_injector_config()</script><script defer src="https://npm.elemecdn.com/hexo-butterfly-swiper/lib/swiper.min.js"></script><script defer data-pjax src="https://alicdn.dingzh.cc/ding-BlogDemo/js/other/swiper_init.min.js"></script><div class="js-pjax"><script async>for(var arr=document.getElementsByClassName("recent-post-item"),i=0;i<arr.length;i++)arr[i].classList.add("wow"),arr[i].classList.add("animate__fadeIn"),arr[i].setAttribute("data-wow-duration",""),arr[i].setAttribute("data-wow-delay",""),arr[i].setAttribute("data-wow-offset",""),arr[i].setAttribute("data-wow-iteration","")</script><script async>for(var arr=document.getElementsByClassName("card-widget"),i=0;i<arr.length;i++)arr[i].classList.add("wow"),arr[i].classList.add("animate__fadeIn"),arr[i].setAttribute("data-wow-duration",""),arr[i].setAttribute("data-wow-delay",""),arr[i].setAttribute("data-wow-offset",""),arr[i].setAttribute("data-wow-iteration","")</script><script async>for(var arr=document.getElementsByClassName("article-sort-item"),i=0;i<arr.length;i++)arr[i].classList.add("wow"),arr[i].classList.add("animate__fadeIn"),arr[i].setAttribute("data-wow-duration","1.2s"),arr[i].setAttribute("data-wow-delay",""),arr[i].setAttribute("data-wow-offset",""),arr[i].setAttribute("data-wow-iteration","")</script><script async>for(var arr=document.getElementsByClassName("flink-list-card"),i=0;i<arr.length;i++)arr[i].classList.add("wow"),arr[i].classList.add("animate__backInDown"),arr[i].setAttribute("data-wow-duration","1.2s"),arr[i].setAttribute("data-wow-delay",""),arr[i].setAttribute("data-wow-offset",""),arr[i].setAttribute("data-wow-iteration","")</script></div><script defer src="https://npm.elemecdn.com/hexo-butterfly-wowjs/lib/wow.min.js"></script><script defer src="https://npm.elemecdn.com/hexo-butterfly-wowjs/lib/wow_init.js"></script></body></html>